mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 06:55:41 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad974357f4 | ||
|
|
d9dfb5cbb2 |
@@ -105,9 +105,6 @@ jobs:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -119,7 +116,7 @@ jobs:
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
@@ -139,38 +136,9 @@ jobs:
|
||||
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
|
||||
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
|
||||
# Update JWT private key (JWK format for auth service bootstrap)
|
||||
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env.tmp6 || true
|
||||
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env.tmp6
|
||||
# Update Fastmail JMAP credentials for email sending (only if set)
|
||||
# Debug: show whether env vars are set (not their values)
|
||||
echo "FASTMAIL_API_TOKEN set: $([ -n "${FASTMAIL_API_TOKEN:-}" ] && echo 'yes' || echo 'no')"
|
||||
echo "FASTMAIL_FROM_EMAIL set: $([ -n "${FASTMAIL_FROM_EMAIL:-}" ] && echo 'yes' || echo 'no')"
|
||||
echo "FASTMAIL_FROM_NAME set: $([ -n "${FASTMAIL_FROM_NAME:-}" ] && echo 'yes' || echo 'no')"
|
||||
|
||||
cp .env.tmp6 .env.tmp7
|
||||
if [ -n "${FASTMAIL_API_TOKEN:-}" ]; then
|
||||
grep -v '^FASTMAIL_API_TOKEN=' .env.tmp7 > .env.tmp7a || true
|
||||
# Use printf with %s to avoid shell interpretation of special chars
|
||||
printf 'FASTMAIL_API_TOKEN=%s\n' "${FASTMAIL_API_TOKEN}" >> .env.tmp7a
|
||||
mv .env.tmp7a .env.tmp7
|
||||
fi
|
||||
cp .env.tmp7 .env.tmp8
|
||||
if [ -n "${FASTMAIL_FROM_EMAIL:-}" ]; then
|
||||
grep -v '^FASTMAIL_FROM_EMAIL=' .env.tmp8 > .env.tmp8a || true
|
||||
printf 'FASTMAIL_FROM_EMAIL=%s\n' "${FASTMAIL_FROM_EMAIL}" >> .env.tmp8a
|
||||
mv .env.tmp8a .env.tmp8
|
||||
fi
|
||||
cp .env.tmp8 .env
|
||||
if [ -n "${FASTMAIL_FROM_NAME:-}" ]; then
|
||||
grep -v '^FASTMAIL_FROM_NAME=' .env > .env.tmp9 || true
|
||||
printf 'FASTMAIL_FROM_NAME=%s\n' "${FASTMAIL_FROM_NAME}" >> .env.tmp9
|
||||
mv .env.tmp9 .env
|
||||
fi
|
||||
|
||||
# Show what ended up in .env for these vars (masked)
|
||||
echo "Resulting .env FASTMAIL entries:"
|
||||
grep '^FASTMAIL' .env | sed 's/=.*/=***/' || echo "(none)"
|
||||
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5 .env.tmp6 .env.tmp7 .env.tmp8
|
||||
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || true
|
||||
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env
|
||||
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5
|
||||
chmod 600 .env
|
||||
else
|
||||
echo "ERROR: .env file not found. Run main deploy first."
|
||||
|
||||
@@ -53,26 +53,26 @@ jobs:
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== AMD64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
build-sysroot-arm64:
|
||||
@@ -114,24 +114,24 @@ jobs:
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== ARM64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot_arm64\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
+118
-164
@@ -42,13 +42,6 @@ jobs:
|
||||
set -ex
|
||||
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
|
||||
|
||||
# Also build the warmup tool for Linux
|
||||
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
|
||||
|
||||
# Copy warmup binary to scripts/ so it gets deployed with other scripts
|
||||
mkdir -p scripts/bin
|
||||
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
@@ -428,7 +421,7 @@ jobs:
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
|
||||
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
@@ -450,174 +443,135 @@ jobs:
|
||||
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
|
||||
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Copy config files to droplet
|
||||
run: |
|
||||
scp -i ~/.ssh/deploy_key \
|
||||
docker-compose.prod.yml nginx/nginx.conf \
|
||||
scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/bin/warmup \
|
||||
deploy@"$DO_DROPLET_IP":/opt/eagle0/
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
source: "docker-compose.prod.yml,nginx/nginx.conf"
|
||||
target: "/opt/eagle0"
|
||||
|
||||
- name: Deploy to production droplet
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,SHARDOK_ADDRESS,SHARDOK_AUTH_TOKEN,SENTRY_DSN
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Environment variables passed via heredoc
|
||||
EAGLE_IMAGE="${EAGLE_IMAGE}"
|
||||
SHARDOK_IMAGE="${SHARDOK_IMAGE}"
|
||||
ADMIN_IMAGE="${ADMIN_IMAGE}"
|
||||
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY}"
|
||||
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
|
||||
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
|
||||
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
|
||||
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
|
||||
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
|
||||
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
|
||||
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
|
||||
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
|
||||
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
|
||||
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
|
||||
SENTRY_DSN="${SENTRY_DSN}"
|
||||
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
|
||||
|
||||
# Check Docker has IPv6 support for connecting to Hetzner Shardok
|
||||
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
|
||||
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
||||
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
||||
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
|
||||
fi
|
||||
|
||||
# Write env vars to .env file for docker-compose
|
||||
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
|
||||
EXISTING_AUTH_IMAGE=""
|
||||
if [ -f .env ]; then
|
||||
EXISTING_AUTH_IMAGE=\$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
|
||||
fi
|
||||
|
||||
rm -f .env 2>/dev/null || true
|
||||
cat > .env << EOF
|
||||
EAGLE_IMAGE=\${EAGLE_IMAGE}
|
||||
SHARDOK_IMAGE=\${SHARDOK_IMAGE}
|
||||
ADMIN_IMAGE=\${ADMIN_IMAGE}
|
||||
JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}
|
||||
AUTH_IMAGE=\${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
OPENAI_API_KEY=\${OPENAI_API_KEY:-}
|
||||
GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}
|
||||
EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}
|
||||
DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY:-}
|
||||
DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY:-}
|
||||
JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY:-}
|
||||
DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID:-}
|
||||
DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET:-}
|
||||
GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET:-}
|
||||
SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}
|
||||
SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN:-}
|
||||
SENTRY_DSN=\${SENTRY_DSN:-}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
|
||||
# Login to registry
|
||||
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
|
||||
|
||||
# Use exact image tags passed from build jobs (no :latest fallback)
|
||||
# Note: AUTH_IMAGE is managed separately by auth_build.yml
|
||||
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
||||
|
||||
# Use crane to pull images (handles OCI format correctly) then load into Docker
|
||||
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
|
||||
echo "Installing crane..."
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
|
||||
# crane uses Docker config for auth
|
||||
echo "Pulling Eagle image with crane..."
|
||||
./crane pull "\${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
|
||||
echo "Loading Eagle image into Docker..."
|
||||
docker load -i eagle.tar
|
||||
rm eagle.tar
|
||||
|
||||
echo "Pulling Shardok image with crane..."
|
||||
./crane pull "\${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
|
||||
echo "Loading Shardok image into Docker..."
|
||||
docker load -i shardok.tar
|
||||
rm shardok.tar
|
||||
|
||||
echo "Pulling Admin image with crane..."
|
||||
./crane pull "\${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
|
||||
echo "Loading Admin image into Docker..."
|
||||
docker load -i admin.tar
|
||||
rm admin.tar
|
||||
|
||||
echo "Pulling JFR Sidecar image with crane..."
|
||||
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
|
||||
echo "Loading JFR Sidecar image into Docker..."
|
||||
docker load -i jfr-sidecar.tar
|
||||
rm jfr-sidecar.tar
|
||||
|
||||
rm ./crane
|
||||
|
||||
# Also pull other compose images
|
||||
docker pull nginx:alpine || true
|
||||
docker pull certbot/certbot || true
|
||||
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Recreate non-Eagle services (not auth - managed by auth_build.yml)
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin jfr-sidecar
|
||||
|
||||
# Deploy Eagle with blue-green (zero-downtime) if scripts are available
|
||||
if [ -x "/opt/eagle0/scripts/deploy-blue-green.sh" ]; then
|
||||
echo "Using blue-green deployment for Eagle..."
|
||||
chmod +x /opt/eagle0/scripts/*.sh
|
||||
# Make warmup binary executable if present
|
||||
if [ -f "/opt/eagle0/scripts/bin/warmup" ]; then
|
||||
chmod +x /opt/eagle0/scripts/bin/warmup
|
||||
# Check Docker has IPv6 support for connecting to Hetzner Shardok
|
||||
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
|
||||
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
||||
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
||||
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
|
||||
fi
|
||||
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
|
||||
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}" || {
|
||||
echo "Blue-green deployment failed, falling back to direct restart"
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
|
||||
}
|
||||
else
|
||||
echo "Blue-green scripts not found, using direct restart..."
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
|
||||
fi
|
||||
|
||||
# Ensure auth is running (but don't force-recreate it)
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
# Write env vars to .env file for docker-compose
|
||||
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
|
||||
EXISTING_AUTH_IMAGE=""
|
||||
if [ -f .env ]; then
|
||||
EXISTING_AUTH_IMAGE=$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
|
||||
fi
|
||||
|
||||
# Restart nginx to pick up new container IPs
|
||||
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
|
||||
rm -f .env 2>/dev/null || true
|
||||
cat > .env << EOF
|
||||
EAGLE_IMAGE=${EAGLE_IMAGE}
|
||||
SHARDOK_IMAGE=${SHARDOK_IMAGE}
|
||||
ADMIN_IMAGE=${ADMIN_IMAGE}
|
||||
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
|
||||
AUTH_IMAGE=${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
|
||||
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
|
||||
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
|
||||
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
|
||||
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
|
||||
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
|
||||
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
|
||||
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
SHARDOK_ADDRESS=${SHARDOK_ADDRESS:-shardok:40042}
|
||||
SHARDOK_AUTH_TOKEN=${SHARDOK_AUTH_TOKEN:-}
|
||||
SENTRY_DSN=${SENTRY_DSN:-}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
|
||||
# Cleanup orphaned containers
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
# Wait for health checks
|
||||
sleep 10
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
# Use exact image tags passed from build jobs (no :latest fallback)
|
||||
# Note: AUTH_IMAGE is managed separately by auth_build.yml
|
||||
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
|
||||
|
||||
# Verify containers are using correct images
|
||||
echo "=== Verifying container image tags ==="
|
||||
docker compose -f docker-compose.prod.yml images
|
||||
# Use crane to pull images (handles OCI format correctly) then load into Docker
|
||||
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
|
||||
echo "Installing crane..."
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
DEPLOY_SCRIPT
|
||||
# crane uses Docker config for auth
|
||||
echo "Pulling Eagle image with crane..."
|
||||
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
|
||||
echo "Loading Eagle image into Docker..."
|
||||
docker load -i eagle.tar
|
||||
rm eagle.tar
|
||||
|
||||
echo "Pulling Shardok image with crane..."
|
||||
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
|
||||
echo "Loading Shardok image into Docker..."
|
||||
docker load -i shardok.tar
|
||||
rm shardok.tar
|
||||
|
||||
echo "Pulling Admin image with crane..."
|
||||
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
|
||||
echo "Loading Admin image into Docker..."
|
||||
docker load -i admin.tar
|
||||
rm admin.tar
|
||||
|
||||
echo "Pulling JFR Sidecar image with crane..."
|
||||
./crane pull "${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
|
||||
echo "Loading JFR Sidecar image into Docker..."
|
||||
docker load -i jfr-sidecar.tar
|
||||
rm jfr-sidecar.tar
|
||||
|
||||
rm ./crane
|
||||
|
||||
# Also pull other compose images
|
||||
docker pull nginx:alpine || true
|
||||
docker pull certbot/certbot || true
|
||||
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Recreate only the services we're updating (not auth - managed by auth_build.yml)
|
||||
# This prevents restarting auth service during every Eagle deploy
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle shardok admin jfr-sidecar
|
||||
|
||||
# Ensure auth is running (but don't force-recreate it)
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
|
||||
# Restart nginx to pick up new container IPs
|
||||
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
|
||||
|
||||
# Cleanup orphaned containers
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
# Wait for health checks
|
||||
sleep 10
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Verify containers are using correct images
|
||||
echo "=== Verifying container image tags ==="
|
||||
docker compose -f docker-compose.prod.yml images
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
@@ -40,15 +40,13 @@ jobs:
|
||||
echo ""
|
||||
|
||||
# List of repositories to clean
|
||||
# Use tail to skip header row in case --no-header doesn't work
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header)
|
||||
|
||||
for REPO in $REPOS; do
|
||||
echo "=== Processing repository: ${REPO} ==="
|
||||
|
||||
# Get all manifests with their tags and dates
|
||||
# Filter out header row and empty lines
|
||||
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null | grep -v '^Digest' | grep -v '^$' || echo "")
|
||||
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$MANIFESTS" ]; then
|
||||
echo " No manifests found"
|
||||
@@ -56,8 +54,8 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
|
||||
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
|
||||
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
|
||||
# Skip if no digest
|
||||
if [ -z "$DIGEST" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
@@ -159,63 +159,51 @@ jobs:
|
||||
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
deploy-hetzner:
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-shardok-arm64]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
SHARDOK_IMAGE: ${{ needs.build-shardok-arm64.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.HETZNER_SSH_KEY }}" > ~/.ssh/hetzner_deploy
|
||||
chmod 600 ~/.ssh/hetzner_deploy
|
||||
# Add host key to known_hosts to avoid prompt
|
||||
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Deploy to Hetzner
|
||||
run: |
|
||||
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.HETZNER_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.HETZNER_SSH_KEY }}
|
||||
protocol: tcp6
|
||||
script_stop: true
|
||||
envs: SHARDOK_IMAGE
|
||||
script: |
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
echo "Deploying Shardok ARM64: $SHARDOK_IMAGE"
|
||||
|
||||
# Pull the new image
|
||||
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
# Pull the new image
|
||||
docker pull "$SHARDOK_IMAGE"
|
||||
|
||||
# Stop and remove any container using port 40042 or named shardok*
|
||||
docker ps -q --filter "publish=40042" | xargs -r docker stop
|
||||
docker ps -aq --filter "name=shardok" | xargs -r docker rm -f
|
||||
docker ps -aq --filter "publish=40042" | xargs -r docker rm -f
|
||||
# Stop and remove existing container
|
||||
docker stop shardok-ai 2>/dev/null || true
|
||||
docker rm shardok-ai 2>/dev/null || true
|
||||
|
||||
# Run new container
|
||||
docker run -d \
|
||||
--name shardok-ai \
|
||||
--restart unless-stopped \
|
||||
-p 40042:40042 \
|
||||
-v /opt/eagle0/data:/data \
|
||||
-v /etc/shardok:/etc/shardok:ro \
|
||||
-v /etc/letsencrypt:/etc/letsencrypt:ro \
|
||||
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
|
||||
-e SHARDOK_RESOURCES_PATH=/app/resources \
|
||||
-e SHARDOK_MAPS_PATH=/app/resources/maps \
|
||||
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
# Run new container
|
||||
docker run -d \
|
||||
--name shardok-ai \
|
||||
--restart unless-stopped \
|
||||
-p 40042:40042 \
|
||||
-v /opt/eagle0/data:/data \
|
||||
"$SHARDOK_IMAGE"
|
||||
|
||||
# Wait and verify
|
||||
sleep 5
|
||||
docker ps | grep shardok-ai
|
||||
# Wait and verify
|
||||
sleep 5
|
||||
docker ps | grep shardok-ai
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
echo "=== Hetzner deployment complete ==="
|
||||
ENDSSH
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/hetzner_deploy
|
||||
echo "=== Hetzner deployment complete ==="
|
||||
|
||||
+2
-2
@@ -88,14 +88,14 @@ sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
|
||||
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
# ARM64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot_arm64",
|
||||
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
|
||||
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
#
|
||||
|
||||
+8
-68
@@ -8,13 +8,9 @@
|
||||
# Run: docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
# Blue-green deployment: eagle-blue is the primary (production) instance
|
||||
# eagle-green is the staging instance for zero-downtime deployments
|
||||
# See scripts/deploy-blue-green.sh for deployment workflow
|
||||
|
||||
eagle-blue:
|
||||
eagle:
|
||||
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-blue
|
||||
container_name: eagle-server
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
@@ -43,7 +39,7 @@ services:
|
||||
volumes:
|
||||
- ./saves:/app/saves # Game saves and user database
|
||||
- ./archived:/app/archived # Archived completed games
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
|
||||
depends_on:
|
||||
@@ -62,58 +58,6 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
eagle-green:
|
||||
image: ${EAGLE_IMAGE_NEW:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-green
|
||||
profiles: ["blue-green"] # Only started during blue-green deployment
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "${SHARDOK_ADDRESS:-shardok:40042}"
|
||||
- "--auth-service-url"
|
||||
- "auth:40033"
|
||||
ports:
|
||||
- "40034:40032" # Different host port for staging
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
EAGLE_ARCHIVE_DIR: "/app/archived"
|
||||
SENTRY_DSN: "${SENTRY_DSN:-}"
|
||||
SENTRY_ENVIRONMENT: "production"
|
||||
volumes:
|
||||
- ./saves:/app/saves # Same save directory as blue
|
||||
- ./archived:/app/archived # Same archive directory as blue
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
|
||||
depends_on:
|
||||
- shardok
|
||||
- auth
|
||||
restart: "no" # Don't auto-restart during deployment
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 60s
|
||||
|
||||
# Backward compatibility alias - for scripts that reference 'eagle' service
|
||||
eagle:
|
||||
extends:
|
||||
service: eagle-blue
|
||||
|
||||
auth:
|
||||
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
container_name: auth-server
|
||||
@@ -136,10 +80,6 @@ services:
|
||||
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
|
||||
JWT_KEYS_PATH: "/etc/eagle0/keys"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
# Fastmail JMAP API for sending invitation emails
|
||||
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
|
||||
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
|
||||
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
|
||||
# Note: port 40033 is exposed via nginx, not directly
|
||||
volumes:
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
|
||||
@@ -196,7 +136,7 @@ services:
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
- ./auth:/etc/nginx/auth:ro
|
||||
depends_on:
|
||||
- eagle-blue
|
||||
- eagle
|
||||
- admin
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
@@ -210,7 +150,7 @@ services:
|
||||
container_name: admin-server
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
|
||||
- "eagle:40032"
|
||||
- "--auth-addr"
|
||||
- "auth:40033"
|
||||
- "--jfr-sidecar-addr"
|
||||
@@ -219,7 +159,7 @@ services:
|
||||
- "8080"
|
||||
# No external port - accessed via nginx at admin.eagle0.net
|
||||
depends_on:
|
||||
- eagle-blue
|
||||
- eagle
|
||||
- auth
|
||||
- jfr-sidecar
|
||||
restart: unless-stopped
|
||||
@@ -239,11 +179,11 @@ services:
|
||||
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
|
||||
container_name: jfr-sidecar
|
||||
# Share PID namespace with Eagle to access its JVM via jcmd
|
||||
pid: "service:eagle-blue"
|
||||
pid: "service:eagle"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle-blue
|
||||
- eagle
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
|
||||
+1
-3
@@ -25,10 +25,8 @@ http {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Upstream for Eagle gRPC server
|
||||
# For blue-green deployments, this is switched between eagle-blue:40032 and eagle-green:40032
|
||||
# by scripts/deploy-blue-green.sh, then nginx is reloaded with 'nginx -s reload'
|
||||
upstream eagle_grpc {
|
||||
server eagle-blue:40032;
|
||||
server eagle:40032;
|
||||
keepalive 100;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Blue-Green Deployment Script for Eagle Server
|
||||
#
|
||||
# This script performs a zero-downtime deployment by:
|
||||
# 1. Starting the new version on a staging port (green)
|
||||
# 2. Waiting for it to become healthy
|
||||
# 3. Running warmup traffic to pre-heat the JIT
|
||||
# 4. Stopping the old version (blue) - which flushes state to disk
|
||||
# 5. Telling green to reload games from disk
|
||||
# 6. Switching nginx to route traffic to green
|
||||
# 7. Cleaning up
|
||||
#
|
||||
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
|
||||
#
|
||||
# Example:
|
||||
# ./deploy-blue-green.sh latest
|
||||
# ./deploy-blue-green.sh sha-abc123
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
|
||||
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
|
||||
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Determine which instance is currently active
|
||||
get_active_instance() {
|
||||
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
|
||||
echo "blue"
|
||||
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
|
||||
echo "green"
|
||||
else
|
||||
log_error "Cannot determine active instance from nginx config"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait for a container to be healthy
|
||||
wait_for_healthy() {
|
||||
local container=$1
|
||||
local max_attempts=${2:-60}
|
||||
local attempt=1
|
||||
|
||||
log_info "Waiting for ${container} to become healthy..."
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
|
||||
if [ "$health" = "healthy" ]; then
|
||||
log_info "${container} is healthy"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo ""
|
||||
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main deployment logic
|
||||
main() {
|
||||
local new_tag="${1:-latest}"
|
||||
local registry="registry.digitalocean.com/eagle0/eagle-server"
|
||||
local new_image="${registry}:${new_tag}"
|
||||
|
||||
log_info "Starting blue-green deployment"
|
||||
log_info "New image: ${new_image}"
|
||||
|
||||
cd "${APP_DIR}"
|
||||
|
||||
# Determine current active instance
|
||||
local active=$(get_active_instance)
|
||||
local staging
|
||||
if [ "$active" = "blue" ]; then
|
||||
staging="green"
|
||||
else
|
||||
staging="blue"
|
||||
fi
|
||||
|
||||
log_info "Active instance: eagle-${active}"
|
||||
log_info "Staging instance: eagle-${staging}"
|
||||
|
||||
# Pull the new image
|
||||
log_info "Pulling new image..."
|
||||
docker pull "${new_image}"
|
||||
|
||||
# Start staging instance with new image
|
||||
log_info "Starting eagle-${staging} with new image..."
|
||||
if [ "$staging" = "green" ]; then
|
||||
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green
|
||||
else
|
||||
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue
|
||||
fi
|
||||
|
||||
# Wait for staging to be healthy
|
||||
if ! wait_for_healthy "eagle-${staging}" 90; then
|
||||
log_error "Staging instance failed health check, aborting deployment"
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run warmup/smoke test
|
||||
local staging_port
|
||||
if [ "$staging" = "green" ]; then
|
||||
staging_port=40034
|
||||
else
|
||||
staging_port=40032
|
||||
fi
|
||||
|
||||
log_info "Running warmup against eagle-${staging}..."
|
||||
if [ -x "${WARMUP_SCRIPT}" ]; then
|
||||
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
|
||||
log_error "Warmup/smoke test failed, aborting deployment"
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
|
||||
log_warn "JIT will be cold on first requests"
|
||||
fi
|
||||
|
||||
# Stop the active instance (this flushes state to disk)
|
||||
log_info "Stopping eagle-${active} (flushing state to disk)..."
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
|
||||
|
||||
# Tell staging to reload games from disk
|
||||
log_info "Telling eagle-${staging} to reload games from disk..."
|
||||
if command -v grpcurl &> /dev/null; then
|
||||
grpcurl -plaintext -d '{}' "localhost:${staging_port}" net.eagle0.eagle.api.Eagle/ReloadGames || true
|
||||
else
|
||||
log_warn "grpcurl not installed, skipping game reload"
|
||||
log_warn "New instance will use games loaded at startup"
|
||||
fi
|
||||
|
||||
# Switch nginx upstream
|
||||
log_info "Switching nginx upstream to eagle-${staging}..."
|
||||
if [ "$staging" = "green" ]; then
|
||||
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
|
||||
else
|
||||
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
|
||||
fi
|
||||
|
||||
# Reload nginx
|
||||
log_info "Reloading nginx..."
|
||||
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
|
||||
|
||||
# Clean up old instance
|
||||
log_info "Removing old eagle-${active} container..."
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
|
||||
|
||||
# Update the staging instance's restart policy and image var
|
||||
# For blue, we need to update EAGLE_IMAGE; for green, update EAGLE_IMAGE_NEW
|
||||
if [ "$staging" = "blue" ]; then
|
||||
log_info "Updating EAGLE_IMAGE to ${new_image} for future restarts"
|
||||
# User should update their .env file
|
||||
else
|
||||
log_info "Green is now active. Consider switching to blue on next deployment."
|
||||
fi
|
||||
|
||||
log_info "Deployment complete!"
|
||||
log_info "Active instance: eagle-${staging}"
|
||||
log_info ""
|
||||
log_info "Note: Update your .env file with EAGLE_IMAGE=${new_image}"
|
||||
log_info " if you want future 'docker compose up' to use this version."
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
check_requirements() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "docker is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v sed &> /dev/null; then
|
||||
log_error "sed is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${NGINX_CONF}" ]; then
|
||||
log_error "nginx config not found at ${NGINX_CONF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${COMPOSE_FILE}" ]; then
|
||||
log_error "docker-compose file not found at ${COMPOSE_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run
|
||||
check_requirements
|
||||
main "$@"
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Warmup Script for Eagle Server
|
||||
#
|
||||
# This script warms up the JIT compiler before switching traffic to a new instance.
|
||||
# It uses the Go warmup tool which:
|
||||
# 1. Creates a test game via bidirectional streaming
|
||||
# 2. Posts an Improve command
|
||||
# 3. Verifies action results and new commands
|
||||
# 4. Cleans up the test game
|
||||
#
|
||||
# Usage: ./warmup-eagle.sh HOST:PORT
|
||||
#
|
||||
# Example:
|
||||
# ./warmup-eagle.sh localhost:40032
|
||||
# ./warmup-eagle.sh localhost:40034
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
HOST="${1:-localhost:40032}"
|
||||
|
||||
log_info "Warming up Eagle server at ${HOST}..."
|
||||
|
||||
# Try to find the Go warmup tool
|
||||
WARMUP_TOOL=""
|
||||
|
||||
# Check if we're in the project directory with bazel
|
||||
if [ -f "${PROJECT_ROOT}/WORKSPACE" ] || [ -f "${PROJECT_ROOT}/WORKSPACE.bazel" ]; then
|
||||
# Try to find the pre-built binary
|
||||
BAZEL_BIN="${PROJECT_ROOT}/bazel-bin/src/main/go/net/eagle0/warmup/warmup_/warmup"
|
||||
if [ -x "${BAZEL_BIN}" ]; then
|
||||
WARMUP_TOOL="${BAZEL_BIN}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for the warmup tool in common locations (for deployed environments)
|
||||
if [ -z "${WARMUP_TOOL}" ]; then
|
||||
for path in \
|
||||
"${SCRIPT_DIR}/bin/warmup" \
|
||||
"/opt/eagle0/scripts/bin/warmup" \
|
||||
"/opt/eagle0/bin/warmup" \
|
||||
"/usr/local/bin/eagle-warmup" \
|
||||
"${SCRIPT_DIR}/warmup"; do
|
||||
if [ -x "${path}" ]; then
|
||||
WARMUP_TOOL="${path}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# If we found the Go tool, use it
|
||||
if [ -n "${WARMUP_TOOL}" ]; then
|
||||
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
|
||||
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=60s; then
|
||||
log_info "Warmup complete!"
|
||||
exit 0
|
||||
else
|
||||
log_error "Go warmup tool failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to grpcurl-based warmup
|
||||
log_warn "Go warmup tool not found, falling back to grpcurl"
|
||||
|
||||
# Check for grpcurl
|
||||
if ! command -v grpcurl &> /dev/null; then
|
||||
log_error "Neither Go warmup tool nor grpcurl is available"
|
||||
log_error "Build the warmup tool with: bazel build //src/main/go/net/eagle0/warmup"
|
||||
log_error "Or install grpcurl: brew install grpcurl (macOS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Warmup iterations
|
||||
WARMUP_ITERATIONS=3
|
||||
|
||||
# 1. Call GetRunningGames multiple times - this exercises the gRPC layer and basic game access
|
||||
log_info "Warming up GetRunningGames..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
RESULT=$(grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames 2>&1) || true
|
||||
if echo "$RESULT" | grep -q "games\|{}"; then
|
||||
echo -n "."
|
||||
else
|
||||
log_error "GetRunningGames failed on iteration $i"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# 2. Call GetSettings - exercises settings loading
|
||||
log_info "Warming up GetSettings..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetSettings > /dev/null 2>&1; then
|
||||
echo -n "."
|
||||
else
|
||||
log_warn "GetSettings failed on iteration $i (non-fatal)"
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# 3. Call AddSettings with empty list - exercises settings path
|
||||
log_info "Warming up AddSettings..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
if grpcurl -plaintext -d '{"settings": []}' "${HOST}" net.eagle0.eagle.api.Eagle/AddSettings > /dev/null 2>&1; then
|
||||
echo -n "."
|
||||
else
|
||||
log_warn "AddSettings failed on iteration $i (non-fatal)"
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# Final health check
|
||||
log_info "Verifying server health..."
|
||||
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames > /dev/null 2>&1; then
|
||||
log_info "Health check passed"
|
||||
else
|
||||
log_error "Health check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info ""
|
||||
log_info "Warmup complete (basic mode - bidirectional streaming warmup not available)!"
|
||||
log_info "The JIT should be warmed for:"
|
||||
log_info " - gRPC layer and protobuf parsing"
|
||||
log_info " - Settings loading and management"
|
||||
log_info ""
|
||||
log_warn "Note: For full warmup including game creation and command processing,"
|
||||
log_warn " build and use the Go warmup tool: bazel build //src/main/go/net/eagle0/warmup"
|
||||
@@ -108,7 +108,6 @@
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/FreeForAllDecisionCommandSelector.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerButtonEditor.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandWarningPanelController.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialModalPanel.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Context Menu/ContextMenuManager.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoopEditor.cs" />
|
||||
<Compile Include="Assets/Eagle/BattalionUtils.cs" />
|
||||
@@ -155,7 +154,6 @@
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
|
||||
<Compile Include="Assets/common/ResourceFetcher.cs" />
|
||||
<Compile Include="Assets/Tutorial/Content/TutorialStep.cs" />
|
||||
<Compile Include="Assets/Auth/AuthClient.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
|
||||
@@ -207,7 +205,6 @@
|
||||
<Compile Include="Assets/Eagle/FactionsTableController.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeSlider.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/TutorialState.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
|
||||
<Compile Include="Assets/Bluetooth/DiceVectors.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoop.cs" />
|
||||
@@ -225,10 +222,8 @@
|
||||
<Compile Include="Assets/GUI Pro Kit Fantasy RPG/Extensions/UIParticle/UIParticleSystem.cs" />
|
||||
<Compile Include="Assets/Shardok/SoundManager.cs" />
|
||||
<Compile Include="Assets/Shardok/HexMesh.cs" />
|
||||
<Compile Include="Assets/Tutorial/Content/TutorialSequence.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationRejectedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/DiplomacyCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/Triggers/TutorialTriggerRegistry.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ResolveBreakAllianceCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ResolveAllianceCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/WithdrewForTruceDetailsNotificationGenerator.cs" />
|
||||
@@ -237,7 +232,6 @@
|
||||
<Compile Include="Assets/Eagle/CustomFileLogger.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationAcceptedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ProvinceEventsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Tutorial/TutorialManager.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/FeastCommandSelector.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Animated Icon/AnimatedIconHandler.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/GenericNotificationGenerator.cs" />
|
||||
@@ -321,7 +315,6 @@
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerContextMenu.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceAmbassadorImprisonedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIcon.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialHintIndicator.cs" />
|
||||
<Compile Include="Assets/Bluetooth/UnityDieColors.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/OrganizeTroopsCommandSelector.cs" />
|
||||
<Compile Include="Assets/Shardok/ExtinguishAnimator.cs" />
|
||||
@@ -370,9 +363,7 @@
|
||||
<Compile Include="Assets/Terrain Hexes/Example Scene/BasicHexArranger.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ManagePrisonersCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/SendSuppliesCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialUIManager.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Window/WindowManager.cs" />
|
||||
<Compile Include="Assets/Auth/InvitationCodeManager.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroExiledDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/SuppressBeastsSucceededNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Shardok/ReservesTableController.cs" />
|
||||
@@ -392,7 +383,6 @@
|
||||
<Compile Include="Assets/Eagle/Notifications/FactionDestroyedNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerNotification.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/HandleCapturedHeroesCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/UI/TutorialOverlayController.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ARNNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/AttackDecisionCommandSelector.cs" />
|
||||
<Compile Include="Assets/Eagle/Table Rows/AvailableHeroTableRow.cs" />
|
||||
|
||||
@@ -58,19 +58,10 @@ namespace Auth {
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
/// <param name="provider">Discord or Google</param>
|
||||
/// <param name="invitationCode">Optional invitation code for new account
|
||||
/// registration</param> <returns>Tuple of (URL to open in browser, state token for
|
||||
/// polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(
|
||||
OAuthProvider provider,
|
||||
string invitationCode = null) {
|
||||
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
|
||||
var request = new GetOAuthUrlRequest { Provider = provider };
|
||||
|
||||
if (!string.IsNullOrEmpty(invitationCode)) {
|
||||
request.InvitationCode = invitationCode;
|
||||
Debug.Log("[AuthClient] Including invitation code in OAuth request");
|
||||
}
|
||||
|
||||
var response = await _authServiceClient.GetOAuthUrlAsync(request);
|
||||
|
||||
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
|
||||
@@ -103,10 +94,6 @@ namespace Auth {
|
||||
case OAuthStatus.Expired:
|
||||
throw new Exception("OAuth session expired. Please try again.");
|
||||
|
||||
case OAuthStatus.InvitationRequired:
|
||||
Debug.Log("[AuthClient] Server requires invitation code for new account");
|
||||
return response; // Return so OAuthManager can handle this
|
||||
|
||||
case OAuthStatus.Pending:
|
||||
// Check timeout
|
||||
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
|
||||
@@ -183,16 +170,18 @@ namespace Auth {
|
||||
|
||||
/// <summary>
|
||||
/// Logout - invalidates refresh token on server.
|
||||
/// Does NOT clear local tokens (OAuthManager handles that decision).
|
||||
/// Routed to Eagle.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
try {
|
||||
await _eagleClient.LogoutAsync(new LogoutRequest());
|
||||
Debug.Log("[AuthClient] Server logout successful");
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
|
||||
}
|
||||
|
||||
// Clear local tokens regardless of server response
|
||||
TokenStorage.Clear();
|
||||
Debug.Log("[AuthClient] Logged out, tokens cleared");
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Manages invitation codes for new account registration.
|
||||
/// Reads codes from installer-provided file or allows manual entry.
|
||||
/// </summary>
|
||||
public static class InvitationCodeManager {
|
||||
private const string InvitationFileName = "invitation.json";
|
||||
private const string PlayerPrefsKey = "InvitationCode";
|
||||
|
||||
// Cache the code to avoid repeated file reads
|
||||
private static string _cachedCode;
|
||||
private static bool _cacheInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Get the invitation code, if available.
|
||||
/// Checks: 1) Cached value, 2) PlayerPrefs, 3) File from installer
|
||||
/// </summary>
|
||||
public static string GetInvitationCode() {
|
||||
if (_cacheInitialized) { return _cachedCode; }
|
||||
|
||||
// Check PlayerPrefs first (manual entry takes priority)
|
||||
string prefsCode = PlayerPrefs.GetString(PlayerPrefsKey, null);
|
||||
if (!string.IsNullOrEmpty(prefsCode)) {
|
||||
_cachedCode = prefsCode;
|
||||
_cacheInitialized = true;
|
||||
Debug.Log("[InvitationCodeManager] Using invitation code from PlayerPrefs");
|
||||
return _cachedCode;
|
||||
}
|
||||
|
||||
// Try to read from installer file
|
||||
string fileCode = ReadFromFile();
|
||||
if (!string.IsNullOrEmpty(fileCode)) {
|
||||
_cachedCode = fileCode;
|
||||
_cacheInitialized = true;
|
||||
Debug.Log("[InvitationCodeManager] Using invitation code from installer file");
|
||||
return _cachedCode;
|
||||
}
|
||||
|
||||
_cacheInitialized = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set invitation code manually (e.g., from UI input).
|
||||
/// </summary>
|
||||
public static void SetInvitationCode(string code) {
|
||||
if (string.IsNullOrEmpty(code)) {
|
||||
PlayerPrefs.DeleteKey(PlayerPrefsKey);
|
||||
_cachedCode = null;
|
||||
} else {
|
||||
PlayerPrefs.SetString(PlayerPrefsKey, code);
|
||||
_cachedCode = code;
|
||||
}
|
||||
_cacheInitialized = true;
|
||||
PlayerPrefs.Save();
|
||||
Debug.Log(
|
||||
$"[InvitationCodeManager] Invitation code {(string.IsNullOrEmpty(code) ? "cleared" : "set")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear the invitation code after successful account creation.
|
||||
/// </summary>
|
||||
public static void ClearInvitationCode() {
|
||||
PlayerPrefs.DeleteKey(PlayerPrefsKey);
|
||||
_cachedCode = null;
|
||||
_cacheInitialized = true;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
// Also delete the installer file if it exists
|
||||
DeleteInstallerFile();
|
||||
|
||||
Debug.Log("[InvitationCodeManager] Invitation code cleared");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if an invitation code is available.
|
||||
/// </summary>
|
||||
public static bool HasInvitationCode => !string.IsNullOrEmpty(GetInvitationCode());
|
||||
|
||||
private static string ReadFromFile() {
|
||||
try {
|
||||
string installDir = GetInstallDirectory();
|
||||
if (string.IsNullOrEmpty(installDir)) { return null; }
|
||||
|
||||
string filePath = Path.Combine(installDir, InvitationFileName);
|
||||
if (!File.Exists(filePath)) { return null; }
|
||||
|
||||
string json = File.ReadAllText(filePath);
|
||||
// Simple JSON parsing for {"invitation_code":"XXXX"}
|
||||
var parsed = JsonUtility.FromJson<InvitationFile>(json);
|
||||
return parsed?.invitation_code;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning(
|
||||
$"[InvitationCodeManager] Failed to read invitation file: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteInstallerFile() {
|
||||
try {
|
||||
string installDir = GetInstallDirectory();
|
||||
if (string.IsNullOrEmpty(installDir)) { return; }
|
||||
|
||||
string filePath = Path.Combine(installDir, InvitationFileName);
|
||||
if (File.Exists(filePath)) {
|
||||
File.Delete(filePath);
|
||||
Debug.Log("[InvitationCodeManager] Deleted installer invitation file");
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning(
|
||||
$"[InvitationCodeManager] Failed to delete invitation file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetInstallDirectory() {
|
||||
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||||
// Windows: %LOCALAPPDATA%\eagle0
|
||||
string localAppData =
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(localAppData, "eagle0");
|
||||
#else
|
||||
// Other platforms don't use the Windows installer
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class InvitationFile {
|
||||
public string invitation_code;
|
||||
}
|
||||
}
|
||||
}
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2a19f11d1f82412e8e2811b366113ac
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
@@ -10,7 +9,6 @@ namespace Auth {
|
||||
/// - Opens system browser for OAuth consent
|
||||
/// - Polls server for OAuth completion (no deep links needed)
|
||||
/// - Token storage and refresh
|
||||
/// - Multi-account support
|
||||
/// </summary>
|
||||
public class OAuthManager : MonoBehaviour {
|
||||
public static OAuthManager Instance { get; private set; }
|
||||
@@ -18,14 +16,12 @@ namespace Auth {
|
||||
private AuthClient _authClient;
|
||||
private string _currentAuthServiceUrl;
|
||||
private string _currentEagleUrl;
|
||||
private OAuthProvider _currentLoginProvider; // Track provider during login
|
||||
|
||||
// Events for UI updates
|
||||
public event Action<UserInfo> OnLoginSuccess;
|
||||
public event Action<string> OnLoginFailed;
|
||||
public event Action OnLogout;
|
||||
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
|
||||
public event Action OnInvitationRequired; // new user needs invitation code
|
||||
|
||||
public bool IsAuthenticated => TokenStorage.HasValidToken;
|
||||
public string DisplayName => TokenStorage.DisplayName;
|
||||
@@ -84,14 +80,9 @@ namespace Auth {
|
||||
/// </summary>
|
||||
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
|
||||
EnsureConfigured();
|
||||
_currentLoginProvider = provider; // Track for later storage
|
||||
|
||||
try {
|
||||
// Get invitation code if available (for new account registration)
|
||||
string invitationCode = InvitationCodeManager.GetInvitationCode();
|
||||
|
||||
// Get OAuth URL and state from server
|
||||
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider, invitationCode);
|
||||
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
|
||||
|
||||
// Open system browser
|
||||
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
|
||||
@@ -100,26 +91,16 @@ namespace Auth {
|
||||
// Poll for OAuth completion (server handles the callback)
|
||||
var response = await _authClient.PollForOAuthCompletionAsync(state);
|
||||
|
||||
// Handle invitation required status
|
||||
if (response.Status == OAuthStatus.InvitationRequired) {
|
||||
Debug.Log("[OAuthManager] Server requires invitation code for new account");
|
||||
OnInvitationRequired?.Invoke();
|
||||
throw new Exception("An invitation code is required to create a new account");
|
||||
}
|
||||
|
||||
// Store tokens with provider info
|
||||
var providerName = provider.ToString().ToLowerInvariant();
|
||||
// Store tokens
|
||||
TokenStorage.StoreTokens(
|
||||
response.AccessToken,
|
||||
response.RefreshToken,
|
||||
response.ExpiresAt,
|
||||
response.User.UserId,
|
||||
response.User.DisplayName ?? "",
|
||||
providerName);
|
||||
response.User.DisplayName ?? "");
|
||||
|
||||
// Clear invitation code after successful new account creation
|
||||
// Notify listeners
|
||||
if (response.IsNewUser) {
|
||||
InvitationCodeManager.ClearInvitationCode();
|
||||
OnNewUserNeedsDisplayName?.Invoke(true);
|
||||
} else {
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
@@ -175,15 +156,6 @@ namespace Auth {
|
||||
// Validate session with server
|
||||
try {
|
||||
var response = await _authClient.GetCurrentUserAsync();
|
||||
|
||||
// Check if user still needs to set display name (e.g., closed app before setting
|
||||
// it)
|
||||
if (string.IsNullOrEmpty(response.User.DisplayName)) {
|
||||
Debug.Log("[OAuthManager] Session restored but user needs display name");
|
||||
OnNewUserNeedsDisplayName?.Invoke(false); // false = not a brand new user
|
||||
return true;
|
||||
}
|
||||
|
||||
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
return true;
|
||||
@@ -206,89 +178,16 @@ namespace Auth {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout - clears current session but preserves stored tokens for quick re-login.
|
||||
/// Logout and clear stored tokens.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
// Notify server of logout if connected
|
||||
// LogoutAsync can work even without server connection - just clear local tokens
|
||||
if (_authClient != null) {
|
||||
try {
|
||||
await _authClient.LogoutAsync();
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Server logout failed: {ex.Message}");
|
||||
}
|
||||
await _authClient.LogoutAsync();
|
||||
} else {
|
||||
TokenStorage.Clear();
|
||||
}
|
||||
|
||||
// Clear current account selection but keep tokens stored
|
||||
TokenStorage.ClearCurrentAccount();
|
||||
OnLogout?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all stored accounts for display in UI.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoredAccount> GetStoredAccounts() {
|
||||
return TokenStorage.GetAllAccounts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select and connect with a stored account.
|
||||
/// If token is expired or about to expire, refreshes it first.
|
||||
/// </summary>
|
||||
public async Task<bool> ConnectWithStoredAccountAsync(StoredAccount account) {
|
||||
EnsureConfigured();
|
||||
|
||||
// Select this account as current
|
||||
TokenStorage.SelectAccount(account.AccountKey);
|
||||
|
||||
// Check if token needs refresh
|
||||
if (!account.HasValidToken || account.NeedsRefresh) {
|
||||
if (account.HasRefreshToken) {
|
||||
try {
|
||||
await _authClient.RefreshTokenAsync();
|
||||
Debug.Log($"[OAuthManager] Refreshed token for {account.DisplayName}");
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Token refresh failed: {ex.Message}");
|
||||
// Token refresh failed - need to re-authenticate
|
||||
// Parse provider from account key
|
||||
var provider = account.Provider.ToLowerInvariant() == "google"
|
||||
? OAuthProvider.Google
|
||||
: OAuthProvider.Discord;
|
||||
OnLoginFailed?.Invoke("Session expired. Please sign in again.");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
OnLoginFailed?.Invoke("Session expired. Please sign in again.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session with server
|
||||
try {
|
||||
var response = await _authClient.GetCurrentUserAsync();
|
||||
|
||||
// Check if user still needs to set display name
|
||||
if (string.IsNullOrEmpty(response.User.DisplayName)) {
|
||||
Debug.Log("[OAuthManager] Stored account needs display name");
|
||||
OnNewUserNeedsDisplayName?.Invoke(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"[OAuthManager] Connected with stored account: {response.User.DisplayName}");
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
|
||||
OnLoginFailed?.Invoke("Session invalid. Please sign in again.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a stored account permanently.
|
||||
/// </summary>
|
||||
public void RemoveStoredAccount(StoredAccount account) {
|
||||
TokenStorage.RemoveAccount(account.AccountKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Represents a stored OAuth account with tokens.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class StoredAccount {
|
||||
public string Provider; // "discord" or "google"
|
||||
public string UserId; // OAuth provider user ID
|
||||
public string DisplayName; // User's display name
|
||||
public string AccessToken;
|
||||
public string RefreshToken;
|
||||
public long ExpiresAt; // Unix timestamp
|
||||
|
||||
public string AccountKey => $"{Provider}:{UserId}";
|
||||
|
||||
public bool HasValidToken => !string.IsNullOrEmpty(AccessToken) &&
|
||||
ExpiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
public bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
|
||||
|
||||
public bool NeedsRefresh {
|
||||
get {
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return ExpiresAt > 0 && ExpiresAt - now < 300; // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Container for all stored accounts, serialized to JSON.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class StoredAccountsData {
|
||||
public List<StoredAccount> Accounts = new();
|
||||
public string CurrentAccountKey; // Provider:UserId of active account
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
|
||||
/// Supports multiple accounts - tokens are preserved on logout.
|
||||
/// Values are cached in memory to allow access from background threads.
|
||||
/// In production, consider using platform-specific secure storage
|
||||
/// (Keychain on iOS, Keystore on Android).
|
||||
/// </summary>
|
||||
public static class TokenStorage {
|
||||
private const string AccountsDataKey = "eagle0_accounts_data";
|
||||
private const string AccessTokenKey = "eagle0_access_token";
|
||||
private const string RefreshTokenKey = "eagle0_refresh_token";
|
||||
private const string ExpiresAtKey = "eagle0_expires_at";
|
||||
private const string UserIdKey = "eagle0_user_id";
|
||||
private const string DisplayNameKey = "eagle0_display_name";
|
||||
|
||||
// Legacy keys for migration
|
||||
private const string LegacyAccessTokenKey = "eagle0_access_token";
|
||||
private const string LegacyRefreshTokenKey = "eagle0_refresh_token";
|
||||
private const string LegacyExpiresAtKey = "eagle0_expires_at";
|
||||
private const string LegacyUserIdKey = "eagle0_user_id";
|
||||
private const string LegacyDisplayNameKey = "eagle0_display_name";
|
||||
|
||||
// In-memory cache
|
||||
private static StoredAccountsData _accountsData;
|
||||
// In-memory cache for thread-safe access from background threads
|
||||
// PlayerPrefs can only be accessed from the main thread
|
||||
private static string _cachedAccessToken;
|
||||
private static string _cachedRefreshToken;
|
||||
private static long _cachedExpiresAt;
|
||||
private static string _cachedUserId;
|
||||
private static string _cachedDisplayName;
|
||||
private static bool _cacheInitialized = false;
|
||||
|
||||
/// <summary>
|
||||
@@ -65,245 +29,147 @@ namespace Auth {
|
||||
/// Call this early in app startup (e.g., in Awake or Start).
|
||||
/// </summary>
|
||||
public static void InitializeCache() {
|
||||
var json = PlayerPrefs.GetString(AccountsDataKey, "");
|
||||
|
||||
if (!string.IsNullOrEmpty(json)) {
|
||||
try {
|
||||
_accountsData = JsonUtility.FromJson<StoredAccountsData>(json);
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[TokenStorage] Failed to parse accounts data: {ex.Message}");
|
||||
_accountsData = new StoredAccountsData();
|
||||
}
|
||||
} else {
|
||||
_accountsData = new StoredAccountsData();
|
||||
// Try to migrate legacy single-account data
|
||||
MigrateLegacyData();
|
||||
}
|
||||
|
||||
_cachedAccessToken = PlayerPrefs.GetString(AccessTokenKey, null);
|
||||
_cachedRefreshToken = PlayerPrefs.GetString(RefreshTokenKey, null);
|
||||
_cachedExpiresAt =
|
||||
long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
|
||||
_cachedUserId = PlayerPrefs.GetString(UserIdKey, null);
|
||||
_cachedDisplayName = PlayerPrefs.GetString(DisplayNameKey, null);
|
||||
_cacheInitialized = true;
|
||||
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Cache initialized, {_accountsData.Accounts.Count} accounts stored");
|
||||
$"[TokenStorage] Cache initialized, hasToken={!string.IsNullOrEmpty(_cachedAccessToken)}");
|
||||
}
|
||||
|
||||
private static void MigrateLegacyData() {
|
||||
var legacyAccessToken = PlayerPrefs.GetString(LegacyAccessTokenKey, "");
|
||||
if (string.IsNullOrEmpty(legacyAccessToken)) return;
|
||||
|
||||
var legacyRefreshToken = PlayerPrefs.GetString(LegacyRefreshTokenKey, "");
|
||||
var legacyExpiresAt =
|
||||
long.TryParse(PlayerPrefs.GetString(LegacyExpiresAtKey, "0"), out var val) ? val
|
||||
: 0;
|
||||
var legacyUserId = PlayerPrefs.GetString(LegacyUserIdKey, "");
|
||||
var legacyDisplayName = PlayerPrefs.GetString(LegacyDisplayNameKey, "");
|
||||
|
||||
if (!string.IsNullOrEmpty(legacyUserId)) {
|
||||
// Assume discord for legacy data (most common)
|
||||
var account = new StoredAccount {
|
||||
Provider = "discord",
|
||||
UserId = legacyUserId,
|
||||
DisplayName = legacyDisplayName,
|
||||
AccessToken = legacyAccessToken,
|
||||
RefreshToken = legacyRefreshToken,
|
||||
ExpiresAt = legacyExpiresAt
|
||||
};
|
||||
_accountsData.Accounts.Add(account);
|
||||
_accountsData.CurrentAccountKey = account.AccountKey;
|
||||
SaveAccountsData();
|
||||
|
||||
// Clear legacy keys
|
||||
PlayerPrefs.DeleteKey(LegacyAccessTokenKey);
|
||||
PlayerPrefs.DeleteKey(LegacyRefreshTokenKey);
|
||||
PlayerPrefs.DeleteKey(LegacyExpiresAtKey);
|
||||
PlayerPrefs.DeleteKey(LegacyUserIdKey);
|
||||
PlayerPrefs.DeleteKey(LegacyDisplayNameKey);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log($"[TokenStorage] Migrated legacy account: {account.DisplayName}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveAccountsData() {
|
||||
var json = JsonUtility.ToJson(_accountsData);
|
||||
PlayerPrefs.SetString(AccountsDataKey, json);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all stored accounts.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<StoredAccount> GetAllAccounts() {
|
||||
EnsureInitialized();
|
||||
return _accountsData.Accounts.AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the currently active account, or null if none selected.
|
||||
/// </summary>
|
||||
public static StoredAccount CurrentAccount {
|
||||
public static bool HasValidToken {
|
||||
get {
|
||||
EnsureInitialized();
|
||||
if (string.IsNullOrEmpty(_accountsData.CurrentAccountKey)) return null;
|
||||
return _accountsData.Accounts.Find(
|
||||
a => a.AccountKey == _accountsData.CurrentAccountKey);
|
||||
var token = AccessToken;
|
||||
var expiresAt = ExpiresAt;
|
||||
return !string.IsNullOrEmpty(token) &&
|
||||
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
|
||||
|
||||
public static string AccessToken {
|
||||
get {
|
||||
if (!_cacheInitialized) {
|
||||
Debug.LogWarning(
|
||||
"[TokenStorage] Cache not initialized, returning cached value anyway");
|
||||
}
|
||||
return _cachedAccessToken;
|
||||
}
|
||||
private
|
||||
set {
|
||||
_cachedAccessToken = value;
|
||||
PlayerPrefs.SetString(AccessTokenKey, value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public static string RefreshToken {
|
||||
get => _cachedRefreshToken;
|
||||
private
|
||||
set {
|
||||
_cachedRefreshToken = value;
|
||||
PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public static long ExpiresAt {
|
||||
get => _cachedExpiresAt;
|
||||
private
|
||||
set {
|
||||
_cachedExpiresAt = value;
|
||||
PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
public static string UserId {
|
||||
get => _cachedUserId;
|
||||
private
|
||||
set {
|
||||
_cachedUserId = value;
|
||||
PlayerPrefs.SetString(UserIdKey, value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public static string DisplayName {
|
||||
get => _cachedDisplayName;
|
||||
private
|
||||
set {
|
||||
_cachedDisplayName = value;
|
||||
PlayerPrefs.SetString(DisplayNameKey, value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select an account as the current active account.
|
||||
/// </summary>
|
||||
public static void SelectAccount(string accountKey) {
|
||||
EnsureInitialized();
|
||||
var account = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
|
||||
if (account != null) {
|
||||
_accountsData.CurrentAccountKey = accountKey;
|
||||
SaveAccountsData();
|
||||
Debug.Log($"[TokenStorage] Selected account: {account.DisplayName}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear the current account selection (logout without deleting tokens).
|
||||
/// </summary>
|
||||
public static void ClearCurrentAccount() {
|
||||
EnsureInitialized();
|
||||
_accountsData.CurrentAccountKey = null;
|
||||
SaveAccountsData();
|
||||
Debug.Log("[TokenStorage] Cleared current account selection");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a specific account and its tokens.
|
||||
/// </summary>
|
||||
public static void RemoveAccount(string accountKey) {
|
||||
EnsureInitialized();
|
||||
var removed = _accountsData.Accounts.RemoveAll(a => a.AccountKey == accountKey);
|
||||
if (_accountsData.CurrentAccountKey == accountKey) {
|
||||
_accountsData.CurrentAccountKey = null;
|
||||
}
|
||||
if (removed > 0) {
|
||||
SaveAccountsData();
|
||||
Debug.Log($"[TokenStorage] Removed account: {accountKey}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility properties that reference the current account
|
||||
|
||||
public static bool HasValidToken => CurrentAccount?.HasValidToken ?? false;
|
||||
|
||||
public static bool HasRefreshToken => CurrentAccount?.HasRefreshToken ?? false;
|
||||
|
||||
public static string AccessToken => CurrentAccount?.AccessToken;
|
||||
|
||||
public static string RefreshToken => CurrentAccount?.RefreshToken;
|
||||
|
||||
public static long ExpiresAt => CurrentAccount?.ExpiresAt ?? 0;
|
||||
|
||||
public static string UserId => CurrentAccount?.UserId;
|
||||
|
||||
public static string DisplayName => CurrentAccount?.DisplayName;
|
||||
|
||||
public static bool NeedsRefresh => CurrentAccount?.NeedsRefresh ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Store tokens for an account. Creates new or updates existing.
|
||||
/// Store tokens received from OAuth exchange.
|
||||
/// </summary>
|
||||
public static void StoreTokens(
|
||||
string accessToken,
|
||||
string refreshToken,
|
||||
long expiresAt,
|
||||
string userId,
|
||||
string displayName,
|
||||
string provider = "discord") {
|
||||
EnsureInitialized();
|
||||
string displayName) {
|
||||
AccessToken = accessToken;
|
||||
RefreshToken = refreshToken;
|
||||
ExpiresAt = expiresAt;
|
||||
UserId = userId;
|
||||
DisplayName = displayName;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
var accountKey = $"{provider}:{userId}";
|
||||
var existingAccount = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
|
||||
|
||||
if (existingAccount != null) {
|
||||
existingAccount.AccessToken = accessToken;
|
||||
existingAccount.RefreshToken = refreshToken;
|
||||
existingAccount.ExpiresAt = expiresAt;
|
||||
existingAccount.DisplayName = displayName;
|
||||
} else {
|
||||
var newAccount = new StoredAccount {
|
||||
Provider = provider,
|
||||
UserId = userId,
|
||||
DisplayName = displayName,
|
||||
AccessToken = accessToken,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
_accountsData.Accounts.Add(newAccount);
|
||||
}
|
||||
|
||||
// Set as current account
|
||||
_accountsData.CurrentAccountKey = accountKey;
|
||||
SaveAccountsData();
|
||||
|
||||
Debug.Log($"[TokenStorage] Stored tokens for {displayName} ({provider})");
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Stored tokens for user {displayName} (expires at {expiresAt})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update access token after refresh for the current account.
|
||||
/// Update access token after refresh.
|
||||
/// </summary>
|
||||
public static void UpdateAccessToken(string accessToken, long expiresAt) {
|
||||
EnsureInitialized();
|
||||
var account = CurrentAccount;
|
||||
if (account != null) {
|
||||
account.AccessToken = accessToken;
|
||||
account.ExpiresAt = expiresAt;
|
||||
SaveAccountsData();
|
||||
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
|
||||
}
|
||||
AccessToken = accessToken;
|
||||
ExpiresAt = expiresAt;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update access token for a specific account (by key).
|
||||
/// </summary>
|
||||
public static void
|
||||
UpdateAccessTokenForAccount(string accountKey, string accessToken, long expiresAt) {
|
||||
EnsureInitialized();
|
||||
var account = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
|
||||
if (account != null) {
|
||||
account.AccessToken = accessToken;
|
||||
account.ExpiresAt = expiresAt;
|
||||
SaveAccountsData();
|
||||
Debug.Log(
|
||||
$"[TokenStorage] Updated access token for {account.DisplayName} (expires at {expiresAt})");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update display name for the current account.
|
||||
/// Update display name after user sets it.
|
||||
/// </summary>
|
||||
public static void UpdateDisplayName(string displayName) {
|
||||
EnsureInitialized();
|
||||
var account = CurrentAccount;
|
||||
if (account != null) {
|
||||
account.DisplayName = displayName;
|
||||
SaveAccountsData();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all stored accounts (full reset).
|
||||
/// </summary>
|
||||
public static void ClearAll() {
|
||||
_accountsData = new StoredAccountsData();
|
||||
PlayerPrefs.DeleteKey(AccountsDataKey);
|
||||
DisplayName = displayName;
|
||||
PlayerPrefs.Save();
|
||||
Debug.Log("[TokenStorage] Cleared all accounts");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy Clear method - now just clears current selection, not tokens.
|
||||
/// Clear all stored tokens (logout).
|
||||
/// </summary>
|
||||
public static void Clear() { ClearCurrentAccount(); }
|
||||
public static void Clear() {
|
||||
_cachedAccessToken = null;
|
||||
_cachedRefreshToken = null;
|
||||
_cachedExpiresAt = 0;
|
||||
_cachedUserId = null;
|
||||
_cachedDisplayName = null;
|
||||
|
||||
private static void EnsureInitialized() {
|
||||
if (!_cacheInitialized) {
|
||||
Debug.LogWarning("[TokenStorage] Cache not initialized, initializing now");
|
||||
InitializeCache();
|
||||
PlayerPrefs.DeleteKey(AccessTokenKey);
|
||||
PlayerPrefs.DeleteKey(RefreshTokenKey);
|
||||
PlayerPrefs.DeleteKey(ExpiresAtKey);
|
||||
PlayerPrefs.DeleteKey(UserIdKey);
|
||||
PlayerPrefs.DeleteKey(DisplayNameKey);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log("[TokenStorage] Cleared all tokens");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if token needs refresh (expires within 5 minutes).
|
||||
/// </summary>
|
||||
public static bool NeedsRefresh {
|
||||
get {
|
||||
var expiresAt = ExpiresAt;
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
return expiresAt > 0 && expiresAt - now < 300; // 5 minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+85
-184
@@ -80,6 +80,9 @@ public class AuthInterceptor : Interceptor {
|
||||
};
|
||||
|
||||
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public TMP_Dropdown environmentDropdown;
|
||||
public TMP_InputField nameField;
|
||||
public TMP_InputField passwordField;
|
||||
public TMP_Dropdown resolutionDropdown;
|
||||
|
||||
[Header("OAuth UI")]
|
||||
@@ -88,23 +91,16 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public Button googleLoginButton;
|
||||
public TextMeshProUGUI oauthStatusText;
|
||||
|
||||
[Header("Stored Accounts")]
|
||||
public GameObject storedAccountsContainer;
|
||||
public GameObject storedAccountButtonPrefab;
|
||||
public Sprite discordProviderIcon;
|
||||
public Sprite googleProviderIcon;
|
||||
|
||||
[Header("Display Name Setup")]
|
||||
public GameObject displayNamePanel;
|
||||
public TMP_InputField displayNameField;
|
||||
public Button setDisplayNameButton;
|
||||
public TextMeshProUGUI displayNameErrorText;
|
||||
|
||||
[Header("Invitation Code Entry")]
|
||||
public GameObject invitationCodePanel;
|
||||
public TMP_InputField invitationCodeField;
|
||||
public Button submitInvitationCodeButton;
|
||||
public TextMeshProUGUI invitationCodeErrorText;
|
||||
[Header("Legacy Auth (for testing)")]
|
||||
public GameObject legacyAuthPanel;
|
||||
public Button useLegacyAuthButton;
|
||||
public TextMeshProUGUI useLegacyAuthButtonText;
|
||||
|
||||
[Header("Status Display")]
|
||||
public TextMeshProUGUI connectionStatusText;
|
||||
@@ -118,7 +114,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public Button logoutButton;
|
||||
public Button customBattleButton;
|
||||
public Button cancelCustomBattleButton;
|
||||
public TMP_Dropdown lobbyEnvironmentDropdown;
|
||||
public TextMeshProUGUI lobbyEnvironmentText;
|
||||
public TextMeshProUGUI lobbyUserText;
|
||||
|
||||
public GameObject runningGamesListArea;
|
||||
@@ -146,7 +142,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
private CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly Object pendingReplyLock = new Object();
|
||||
private int _connectedEnvironmentIndex = -1; // -1 means not connected
|
||||
private List<GameObject> _storedAccountButtons = new List<GameObject>();
|
||||
private bool _useOAuth = false; // Default to legacy until server-side OAuth is ready
|
||||
|
||||
public ClientPregeneratedText clientPregeneratedText;
|
||||
|
||||
@@ -170,7 +166,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
: null;
|
||||
|
||||
private string GetUrlFromEnvironment() {
|
||||
int envIndex = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
int envIndex = environmentDropdown.value;
|
||||
string prefix = EnvironmentOptions[envIndex];
|
||||
return prefix + BaseDomain;
|
||||
}
|
||||
@@ -203,6 +199,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
|
||||
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
|
||||
|
||||
// Set up environment dropdown
|
||||
environmentDropdown.ClearOptions();
|
||||
environmentDropdown.AddOptions(EnvironmentDisplayNames);
|
||||
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
|
||||
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
|
||||
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
|
||||
|
||||
connectionCanvas.gameObject.SetActive(true);
|
||||
eagleCanvas.gameObject.SetActive(false);
|
||||
shardokCanvas.gameObject.SetActive(false);
|
||||
@@ -228,7 +232,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void SetupOAuthUI() {
|
||||
// Set up OAuth button click handlers for new sign-ins
|
||||
// Set up OAuth button click handlers
|
||||
if (discordLoginButton != null) {
|
||||
discordLoginButton.onClick.AddListener(
|
||||
() => OnOAuthLoginClicked(OAuthProvider.Discord));
|
||||
@@ -239,8 +243,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
if (setDisplayNameButton != null) {
|
||||
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
|
||||
}
|
||||
if (submitInvitationCodeButton != null) {
|
||||
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
|
||||
if (useLegacyAuthButton != null) {
|
||||
useLegacyAuthButton.onClick.AddListener(OnUseLegacyAuthClicked);
|
||||
}
|
||||
|
||||
// Subscribe to OAuthManager events
|
||||
@@ -249,83 +253,26 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
|
||||
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
|
||||
OAuthManager.Instance.OnLogout += OnOAuthLogout;
|
||||
OAuthManager.Instance.OnInvitationRequired += OnInvitationRequired;
|
||||
}
|
||||
|
||||
// Show auth panel with stored accounts
|
||||
// Show appropriate panel based on auth state
|
||||
ShowAuthPanel();
|
||||
}
|
||||
|
||||
private void ShowAuthPanel() {
|
||||
// Hide other panels
|
||||
// Hide all auth panels first
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
|
||||
|
||||
// Show OAuth panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
|
||||
// Refresh stored account buttons
|
||||
RefreshStoredAccountButtons();
|
||||
}
|
||||
|
||||
private void RefreshStoredAccountButtons() {
|
||||
// Clear existing buttons
|
||||
foreach (var btn in _storedAccountButtons) {
|
||||
if (btn != null) Destroy(btn);
|
||||
}
|
||||
_storedAccountButtons.Clear();
|
||||
|
||||
if (storedAccountsContainer == null || storedAccountButtonPrefab == null) return;
|
||||
|
||||
// Get stored accounts
|
||||
var accounts = OAuthManager.Instance?.GetStoredAccounts();
|
||||
if (accounts == null || accounts.Count == 0) return;
|
||||
|
||||
// Create button for each stored account
|
||||
foreach (var account in accounts) {
|
||||
var buttonObj =
|
||||
Instantiate(storedAccountButtonPrefab, storedAccountsContainer.transform);
|
||||
buttonObj.transform.localScale = Vector3.one;
|
||||
|
||||
// Set button text (just display name, icon shows provider)
|
||||
var buttonText = buttonObj.GetComponentInChildren<TextMeshProUGUI>();
|
||||
if (buttonText != null) { buttonText.text = account.DisplayName; }
|
||||
|
||||
// Set provider icon (look for child named "ProviderIcon")
|
||||
var providerIconTransform = buttonObj.transform.Find("ProviderIcon");
|
||||
if (providerIconTransform != null) {
|
||||
var providerImage = providerIconTransform.GetComponent<Image>();
|
||||
if (providerImage != null) {
|
||||
var isGoogle = account.Provider.ToLowerInvariant() == "google";
|
||||
providerImage.sprite = isGoogle ? googleProviderIcon : discordProviderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
// Set click handler
|
||||
var button = buttonObj.GetComponent<Button>();
|
||||
if (button != null) {
|
||||
var accountCopy = account; // Capture for lambda
|
||||
button.onClick.AddListener(() => OnStoredAccountClicked(accountCopy));
|
||||
}
|
||||
|
||||
_storedAccountButtons.Add(buttonObj);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnStoredAccountClicked(StoredAccount account) {
|
||||
// Configure OAuthManager with URLs
|
||||
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
|
||||
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = $"Connecting as {account.DisplayName}...";
|
||||
}
|
||||
|
||||
var success = await OAuthManager.Instance.ConnectWithStoredAccountAsync(account);
|
||||
if (success) {
|
||||
// OnLoginSuccess will handle connection to lobby
|
||||
// Show appropriate panel based on _useOAuth
|
||||
if (_useOAuth) {
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null)
|
||||
useLegacyAuthButtonText.text = "Use Classic Sign-in";
|
||||
} else {
|
||||
// Failed - might need re-auth, refresh the buttons
|
||||
RefreshStoredAccountButtons();
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,56 +282,22 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
if (cancelCustomBattleButton != null) {
|
||||
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
|
||||
}
|
||||
|
||||
// Set up environment dropdown
|
||||
if (lobbyEnvironmentDropdown != null) {
|
||||
lobbyEnvironmentDropdown.ClearOptions();
|
||||
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
|
||||
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateLobbyStatusDisplays() {
|
||||
// Update environment dropdown selection
|
||||
if (lobbyEnvironmentDropdown != null && _connectedEnvironmentIndex >= 0) {
|
||||
// Temporarily remove listener to avoid triggering reconnect
|
||||
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
|
||||
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
|
||||
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
|
||||
// Show environment (prod/qa)
|
||||
if (lobbyEnvironmentText != null) {
|
||||
lobbyEnvironmentText.text = ConnectedEnvironmentName ?? "";
|
||||
}
|
||||
|
||||
// Show current user from OAuth
|
||||
if (lobbyUserText != null) { lobbyUserText.text = TokenStorage.DisplayName ?? ""; }
|
||||
}
|
||||
|
||||
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
|
||||
if (newEnvironmentIndex == _connectedEnvironmentIndex) return;
|
||||
|
||||
Debug.Log(
|
||||
$"[ConnectionHandler] Switching environment from {ConnectedEnvironmentName} to {EnvironmentDisplayNames[newEnvironmentIndex]}");
|
||||
|
||||
// Disconnect from current environment
|
||||
_persistentClientConnection?.Dispose();
|
||||
eagleConnection?.Dispose();
|
||||
_httpClient?.Dispose();
|
||||
|
||||
_persistentClientConnection = null;
|
||||
eagleConnection = null;
|
||||
_httpClient = null;
|
||||
|
||||
// Update environment index and reconnect
|
||||
_connectedEnvironmentIndex = newEnvironmentIndex;
|
||||
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
|
||||
|
||||
// Clear current game lists while reconnecting
|
||||
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
|
||||
foreach (Transform row in availableGamesListArea.transform) { Destroy(row.gameObject); }
|
||||
|
||||
// Reconnect to new environment
|
||||
_createConnection();
|
||||
RequestMaps();
|
||||
StartListeningForLobbyUpdates();
|
||||
// Show current user - prefer OAuth display name, fall back to classic login name
|
||||
if (lobbyUserText != null) {
|
||||
if (_useOAuth && !string.IsNullOrEmpty(TokenStorage.DisplayName)) {
|
||||
lobbyUserText.text = TokenStorage.DisplayName;
|
||||
} else {
|
||||
lobbyUserText.text = nameField.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnLogoutClicked() {
|
||||
@@ -465,53 +378,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Show display name panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(true);
|
||||
if (displayNameErrorText != null) displayNameErrorText.text = "";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnInvitationRequired() {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Show invitation code entry panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(true);
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text =
|
||||
"An invitation code is required to create a new account.";
|
||||
}
|
||||
if (invitationCodeField != null) invitationCodeField.text = "";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnSubmitInvitationCodeClicked() {
|
||||
if (invitationCodeField == null) return;
|
||||
|
||||
var code = invitationCodeField.text.Trim();
|
||||
if (string.IsNullOrEmpty(code)) {
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text = "Please enter an invitation code";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the code and return to login screen to retry
|
||||
InvitationCodeManager.SetInvitationCode(code);
|
||||
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text = "Code saved. Please sign in again.";
|
||||
}
|
||||
|
||||
// Return to auth panel after a short delay
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = "Invitation code saved. Please sign in to continue.";
|
||||
}
|
||||
ShowAuthPanel();
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnSetDisplayNameClicked() {
|
||||
if (displayNameField == null || OAuthManager.Instance == null) return;
|
||||
|
||||
@@ -539,7 +410,27 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
});
|
||||
}
|
||||
|
||||
private void ConnectWithOAuth() { _internalConnectEagle(); }
|
||||
private void OnUseLegacyAuthClicked() {
|
||||
_useOAuth = !_useOAuth; // Toggle instead of just setting false
|
||||
|
||||
if (_useOAuth) {
|
||||
// Show OAuth panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
|
||||
if (useLegacyAuthButtonText != null)
|
||||
useLegacyAuthButtonText.text = "Use Classic Sign-in";
|
||||
} else {
|
||||
// Show legacy panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
|
||||
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
|
||||
}
|
||||
}
|
||||
|
||||
private void ConnectWithOAuth() {
|
||||
_useOAuth = true;
|
||||
_internalConnectEagle();
|
||||
}
|
||||
|
||||
private float _statusUpdateTimer = 0f;
|
||||
private const float StatusUpdateInterval = 0.5f;
|
||||
@@ -743,7 +634,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void _createConnection() {
|
||||
_connectedEnvironmentIndex = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
_connectedEnvironmentIndex = environmentDropdown.value;
|
||||
PlayerPrefs.SetInt(EnvironmentKey, _connectedEnvironmentIndex);
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
// Dispose existing connections before creating new ones
|
||||
_httpClient?.Dispose();
|
||||
@@ -756,18 +650,25 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
string url = GetUrlFromEnvironment();
|
||||
|
||||
// OAuth JWT-based connection
|
||||
if (!TokenStorage.HasValidToken) {
|
||||
Debug.LogError("[ConnectionHandler] No valid token available for connection");
|
||||
return;
|
||||
if (_useOAuth && TokenStorage.HasValidToken) {
|
||||
// Use JWT-based connection
|
||||
eagleConnection = EagleConnection.CreateWithJwt(url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
|
||||
} else {
|
||||
// Legacy Basic Auth connection
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
|
||||
}
|
||||
|
||||
eagleConnection = EagleConnection.CreateWithJwt(url);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
|
||||
|
||||
_persistentClientConnection = new PersistentClientConnection(
|
||||
eagleConnection.EagleGrpcClient,
|
||||
eagleConnection.credentials,
|
||||
|
||||
LFS
BIN
Binary file not shown.
-117
@@ -1,117 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: af9f637b3a1f6433f819a0bcdb326453
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-427
@@ -1,427 +0,0 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!1 &1262787222422545696
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 6242087783640990751}
|
||||
- component: {fileID: 5444015614922696687}
|
||||
- component: {fileID: 8871991993173924918}
|
||||
- component: {fileID: 6965305925514130928}
|
||||
m_Layer: 5
|
||||
m_Name: ProviderIcon
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &6242087783640990751
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1262787222422545696}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 157686408557551303}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &5444015614922696687
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1262787222422545696}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &8871991993173924918
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1262787222422545696}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 80
|
||||
m_PreferredHeight: 50
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &6965305925514130928
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1262787222422545696}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 21300000, guid: b6b68c8c37d5cf1419267a80c426ac83, type: 3}
|
||||
m_Type: 0
|
||||
m_PreserveAspect: 1
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!1 &5166012670666351313
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 157686408557551303}
|
||||
- component: {fileID: 3634401029247236684}
|
||||
- component: {fileID: 7790188191322585408}
|
||||
- component: {fileID: 646268409010340088}
|
||||
- component: {fileID: 3097176747511135857}
|
||||
- component: {fileID: 7691398338434978342}
|
||||
m_Layer: 5
|
||||
m_Name: StoredAccountButton
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &157686408557551303
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children:
|
||||
- {fileID: 7974514965529143181}
|
||||
- {fileID: 6242087783640990751}
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &3634401029247236684
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &7790188191322585408
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
|
||||
m_Type: 1
|
||||
m_PreserveAspect: 0
|
||||
m_FillCenter: 1
|
||||
m_FillMethod: 4
|
||||
m_FillAmount: 1
|
||||
m_FillClockwise: 1
|
||||
m_FillOrigin: 0
|
||||
m_UseSpriteMesh: 0
|
||||
m_PixelsPerUnitMultiplier: 1
|
||||
--- !u!114 &646268409010340088
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
|
||||
m_Navigation:
|
||||
m_Mode: 3
|
||||
m_WrapAround: 0
|
||||
m_SelectOnUp: {fileID: 0}
|
||||
m_SelectOnDown: {fileID: 0}
|
||||
m_SelectOnLeft: {fileID: 0}
|
||||
m_SelectOnRight: {fileID: 0}
|
||||
m_Transition: 1
|
||||
m_Colors:
|
||||
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
|
||||
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
|
||||
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
|
||||
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
|
||||
m_ColorMultiplier: 1
|
||||
m_FadeDuration: 0.1
|
||||
m_SpriteState:
|
||||
m_HighlightedSprite: {fileID: 0}
|
||||
m_PressedSprite: {fileID: 0}
|
||||
m_SelectedSprite: {fileID: 0}
|
||||
m_DisabledSprite: {fileID: 0}
|
||||
m_AnimationTriggers:
|
||||
m_NormalTrigger: Normal
|
||||
m_HighlightedTrigger: Highlighted
|
||||
m_PressedTrigger: Pressed
|
||||
m_SelectedTrigger: Selected
|
||||
m_DisabledTrigger: Disabled
|
||||
m_Interactable: 1
|
||||
m_TargetGraphic: {fileID: 7790188191322585408}
|
||||
m_OnClick:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
--- !u!114 &3097176747511135857
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 400
|
||||
m_PreferredHeight: 60
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &7691398338434978342
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5166012670666351313}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 30649d3a9faa99c48a7b1166b86bf2a0, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.HorizontalLayoutGroup
|
||||
m_Padding:
|
||||
m_Left: 0
|
||||
m_Right: 0
|
||||
m_Top: 0
|
||||
m_Bottom: 0
|
||||
m_ChildAlignment: 5
|
||||
m_Spacing: 0
|
||||
m_ChildForceExpandWidth: 0
|
||||
m_ChildForceExpandHeight: 0
|
||||
m_ChildControlWidth: 1
|
||||
m_ChildControlHeight: 1
|
||||
m_ChildScaleWidth: 0
|
||||
m_ChildScaleHeight: 0
|
||||
m_ReverseArrangement: 0
|
||||
--- !u!1 &5617793748733146591
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 7974514965529143181}
|
||||
- component: {fileID: 4513536456760004536}
|
||||
- component: {fileID: 6210669843299902677}
|
||||
- component: {fileID: 572895541873054588}
|
||||
m_Layer: 5
|
||||
m_Name: Label
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &7974514965529143181
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5617793748733146591}
|
||||
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 157686408557551303}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!222 &4513536456760004536
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5617793748733146591}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!114 &6210669843299902677
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5617793748733146591}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_text: nolen (dan@danielcrosby.net)
|
||||
m_isRightToLeft: 0
|
||||
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
|
||||
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
|
||||
type: 2}
|
||||
m_fontSharedMaterials: []
|
||||
m_fontMaterial: {fileID: 0}
|
||||
m_fontMaterials: []
|
||||
m_fontColor32:
|
||||
serializedVersion: 2
|
||||
rgba: 4281479730
|
||||
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
|
||||
m_enableVertexGradient: 0
|
||||
m_colorMode: 3
|
||||
m_fontColorGradient:
|
||||
topLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||
topRight: {r: 1, g: 1, b: 1, a: 1}
|
||||
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
|
||||
bottomRight: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_fontColorGradientPreset: {fileID: 0}
|
||||
m_spriteAsset: {fileID: 0}
|
||||
m_tintAllSprites: 0
|
||||
m_StyleSheet: {fileID: 0}
|
||||
m_TextStyleHashCode: -1183493901
|
||||
m_overrideHtmlColors: 0
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
m_fontSizeMax: 72
|
||||
m_fontStyle: 0
|
||||
m_HorizontalAlignment: 2
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 0
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
m_horizontalMapping: 0
|
||||
m_verticalMapping: 0
|
||||
m_uvLineOffset: 0
|
||||
m_geometrySortingOrder: 0
|
||||
m_IsTextObjectScaleStatic: 0
|
||||
m_VertexBufferAutoSizeReduction: 0
|
||||
m_useMaxVisibleDescender: 1
|
||||
m_pageToDisplay: 1
|
||||
m_margin: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_isUsingLegacyAnimationComponent: 0
|
||||
m_isVolumetricText: 0
|
||||
m_hasFontAssetChanged: 0
|
||||
m_baseMaterial: {fileID: 0}
|
||||
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
|
||||
--- !u!114 &572895541873054588
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 5617793748733146591}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: 60
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6440904d10a244010b3a91e0aa247749
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+3
-25
@@ -3,7 +3,6 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using common;
|
||||
using common.GUIUtils;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
@@ -159,11 +158,9 @@ namespace eagle {
|
||||
|
||||
public void StopAll() {
|
||||
_newModel = null;
|
||||
if (ModelUpdater != null) {
|
||||
ModelUpdater.StopListeningForUpdates();
|
||||
ModelUpdater.UpdateAction = null;
|
||||
ModelUpdater = null;
|
||||
}
|
||||
ModelUpdater.StopListeningForUpdates();
|
||||
ModelUpdater.UpdateAction = null;
|
||||
ModelUpdater = null;
|
||||
SwapModel();
|
||||
Model = null;
|
||||
chronicleCanvasController.Entries = new List<ChronicleEntry>();
|
||||
@@ -284,13 +281,6 @@ namespace eagle {
|
||||
MainQueue.Q.EnqueueForNextUpdate(
|
||||
() => { _ = ModelUpdater.StartListeningForUpdates(); });
|
||||
|
||||
// Initialize tutorial system
|
||||
TutorialManager.Instance?.Initialize(this, null);
|
||||
if (TutorialManager.Instance != null &&
|
||||
!TutorialManager.Instance.State.OnboardingCompleted) {
|
||||
TutorialManager.Instance.StartOnboarding();
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
|
||||
#endif
|
||||
@@ -372,12 +362,6 @@ namespace eagle {
|
||||
SetNextActiveProvinceButton();
|
||||
_dominionPanelController.ForceUpdate();
|
||||
SetMusic();
|
||||
|
||||
// Notify tutorial system of province selection
|
||||
if (pid.HasValue) {
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnProvinceSelected(
|
||||
Model.Provinces[pid.Value]);
|
||||
}
|
||||
}
|
||||
|
||||
private void PrefetchHeadshotForHeroes(List<HeroView> heroViews) {
|
||||
@@ -493,9 +477,6 @@ namespace eagle {
|
||||
var oldModel = Model;
|
||||
Model = _newModel;
|
||||
|
||||
// Notify tutorial system of model change
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnModelUpdated(Model, oldModel);
|
||||
|
||||
provinceInfoPanelController.Model = _newModel;
|
||||
freeHeroesTableController.Model = _newModel;
|
||||
movingArmiesTableController.Model = _newModel;
|
||||
@@ -644,9 +625,6 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private void PostCommittedCommand(ProvinceId provinceId, SelectedCommand selectedCommand) {
|
||||
// Notify tutorial system of command
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnCommandIssued(selectedCommand);
|
||||
|
||||
ModelUpdater.PostCommand(provinceId: provinceId, command: selectedCommand)
|
||||
.ContinueWith(response => {
|
||||
if (response.IsFaulted) { errorHandler.Add(response.Exception); }
|
||||
|
||||
+8
-8
@@ -6,7 +6,7 @@ TextureImporter:
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
@@ -37,13 +37,13 @@ TextureImporter:
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
@@ -52,9 +52,9 @@ TextureImporter:
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
@@ -100,7 +100,7 @@ TextureImporter:
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
|
||||
+8
-8
@@ -6,7 +6,7 @@ TextureImporter:
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
enableMipMap: 1
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
@@ -37,13 +37,13 @@ TextureImporter:
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteMode: 0
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
@@ -52,9 +52,9 @@ TextureImporter:
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
@@ -100,7 +100,7 @@ TextureImporter:
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ using System.IO;
|
||||
using common;
|
||||
using eagle;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Shardok;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
@@ -29,9 +28,6 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
public Toggle tooltipHugsCursorToggle;
|
||||
public HoveringTooltip hoveringTooltip;
|
||||
|
||||
public Toggle showAnimationTestsToggle;
|
||||
public AnimationTestController animationTestController;
|
||||
|
||||
public Slider autoScrollSpeedSlider;
|
||||
public TMP_Text autoScrollSpeedLabel;
|
||||
|
||||
@@ -43,7 +39,6 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
private const string MusicVolumeKey = "musicVolume";
|
||||
public const string UseGoDiceKey = "useGoDice";
|
||||
private const string TooltipHugsCursorKey = "tooltipHugsCursor";
|
||||
private const string AnimationToggleKey = "animationToggle";
|
||||
|
||||
public EagleGameController eagleGameController;
|
||||
public GameObject ShardokCanvas;
|
||||
@@ -64,9 +59,6 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
|
||||
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
|
||||
|
||||
showAnimationTestsToggle.isOn = PlayerPrefs.GetInt(AnimationToggleKey, 0) == 1;
|
||||
animationTestController.gameObject.SetActive(showAnimationTestsToggle.isOn);
|
||||
|
||||
if (autoScrollSpeedSlider != null) {
|
||||
autoScrollSpeedSlider.value = AutoScrollingText.GlobalScrollSpeedMultiplier;
|
||||
UpdateAutoScrollSpeedLabel(autoScrollSpeedSlider.value);
|
||||
@@ -124,11 +116,6 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
hoveringTooltip.HugsCursor = val;
|
||||
}
|
||||
|
||||
public void OnAnimationTestToggleChange(bool val) {
|
||||
PlayerPrefs.SetInt(AnimationToggleKey, val ? 1 : 0);
|
||||
animationTestController.gameObject.SetActive(val);
|
||||
}
|
||||
|
||||
public void OnAutoScrollSpeedSliderChange(float val) {
|
||||
AutoScrollingText.GlobalScrollSpeedMultiplier = val;
|
||||
UpdateAutoScrollSpeedLabel(val);
|
||||
|
||||
BIN
Binary file not shown.
-169
@@ -1,169 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 32f7f3d380ec1413590676a13c22324c
|
||||
TextureImporter:
|
||||
internalIDToNameTable:
|
||||
- first:
|
||||
213: 7482667652216324306
|
||||
second: Square
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMasterTextureLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 1
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 2
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 256
|
||||
spriteBorder: {x: 4, y: 4, z: 4, w: 4}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: 0
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites:
|
||||
- serializedVersion: 2
|
||||
name: Triangle
|
||||
rect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
y: 0
|
||||
width: 256
|
||||
height: 256
|
||||
alignment: 9
|
||||
pivot: {x: 0.5, y: 0.28866667}
|
||||
border: {x: 0, y: 0, z: 0, w: 0}
|
||||
outline:
|
||||
- - {x: 0, y: 93.7025033688}
|
||||
- {x: -128, y: -128}
|
||||
- {x: 128, y: -128}
|
||||
physicsShape:
|
||||
- - {x: 0, y: 93.7025033688}
|
||||
- {x: -128, y: -128}
|
||||
- {x: 128, y: -128}
|
||||
tessellationDetail: 0
|
||||
bones: []
|
||||
spriteID: 2d009a6b596c7d760800000000000000
|
||||
internalID: 7482667652216324306
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
outline: []
|
||||
physicsShape:
|
||||
- - {x: -128, y: 128}
|
||||
- {x: -128, y: -128}
|
||||
- {x: 128, y: -128}
|
||||
- {x: 128, y: 128}
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
nameFileIdTable:
|
||||
Triangle: 7482667652216324306
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
-117
@@ -1,117 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7fee21f21c4f146f69dd2eae14a94b4b
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
-117
@@ -1,117 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bfa8cdc5e73d54ed1876c17a47ff7346
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
-117
@@ -1,117 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd23b09c6f27b4754a769858a2020668
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
LFS
BIN
Binary file not shown.
-117
@@ -1,117 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fe839b58f5fd46d695ece8ccf2412a5
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
borderMipMap: 0
|
||||
mipMapsPreserveCoverage: 0
|
||||
alphaTestReferenceValue: 0.5
|
||||
mipMapFadeDistanceStart: 1
|
||||
mipMapFadeDistanceEnd: 3
|
||||
bumpmap:
|
||||
convertToNormalMap: 0
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 0
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+3
-21
@@ -1,4 +1,3 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Shardok {
|
||||
@@ -126,30 +125,13 @@ namespace Shardok {
|
||||
Debug.Log("Melee sequence complete!");
|
||||
}
|
||||
|
||||
public void TestMove() { StartCoroutine(TestMoveSequence()); }
|
||||
|
||||
private IEnumerator TestMoveSequence() {
|
||||
Debug.Log($"Testing Move (Infantry): {sourceCell} -> {targetCell}");
|
||||
public void TestMove() {
|
||||
Debug.Log($"Testing Move: {sourceCell} -> {targetCell}");
|
||||
if (_moveAnimator != null) {
|
||||
_moveAnimator.AnimateMove(
|
||||
sourceCell,
|
||||
targetCell,
|
||||
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.LightInfantry);
|
||||
_moveAnimator.AnimateMove(sourceCell, targetCell);
|
||||
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
|
||||
} else {
|
||||
Debug.LogWarning("MoveAnimator not available");
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(1.5f);
|
||||
|
||||
Debug.Log($"Testing Move (Cavalry): {sourceCell} -> {targetCell}");
|
||||
if (_moveAnimator != null) {
|
||||
_moveAnimator.AnimateMove(
|
||||
sourceCell,
|
||||
targetCell,
|
||||
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.LightCavalry);
|
||||
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -59,8 +59,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
Debug.LogWarning(
|
||||
@@ -68,10 +68,14 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"ArrowVolleyAnimator: Starting volley from cell {sourceCellIndex} to {targetCellIndex}");
|
||||
StartCoroutine(SpawnArrowVolley(sourcePos.Value, targetPos.Value));
|
||||
}
|
||||
|
||||
private IEnumerator SpawnArrowVolley(Vector2 sourcePosition, Vector2 targetPosition) {
|
||||
Debug.Log(
|
||||
$"ArrowVolleyAnimator: Spawning {arrowCount} arrows from {sourcePosition} to {targetPosition}");
|
||||
var arrows = new List<GameObject>();
|
||||
|
||||
for (int i = 0; i < arrowCount; i++) {
|
||||
|
||||
@@ -59,8 +59,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
Debug.LogWarning(
|
||||
|
||||
+217
-137
@@ -7,8 +7,7 @@ using static Net.Eagle0.Shardok.Api.BattalionView.Types;
|
||||
namespace Shardok {
|
||||
/// <summary>
|
||||
/// Animates charge attacks between two hexes.
|
||||
/// Shows attacker's weapon thrusting toward defender with accelerating motion
|
||||
/// and a violent flash on impact.
|
||||
/// Similar to MeleeAnimator but with more dramatic motion for charging units.
|
||||
/// </summary>
|
||||
public class ChargeAnimator : MonoBehaviour {
|
||||
[Header("Weapon Sprites")]
|
||||
@@ -30,41 +29,28 @@ namespace Shardok {
|
||||
[Tooltip("Bone/claw sprite for undead charge")]
|
||||
public Sprite boneSprite;
|
||||
|
||||
[Header("Impact Flash Settings")]
|
||||
[Tooltip("Sprite for impact flash")]
|
||||
public Sprite flashSprite;
|
||||
|
||||
[Tooltip("Color of impact flash")]
|
||||
public Color flashColor = new Color(1f, 1f, 0.8f, 0.9f);
|
||||
|
||||
[Tooltip("Scale of impact flash")]
|
||||
public float flashScale = 35f;
|
||||
|
||||
[Tooltip("Duration of flash")]
|
||||
public float flashDuration = 0.1f;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Base scale of weapon sprites")]
|
||||
public float weaponScale = 25.0f;
|
||||
|
||||
[Tooltip("Number of thrusts")]
|
||||
public int thrustCount = 2;
|
||||
[Tooltip("Duration of the charge thrust in seconds")]
|
||||
public float chargeDuration = 0.15f;
|
||||
|
||||
[Tooltip("Duration of each thrust (slow to fast)")]
|
||||
public float thrustDuration = 0.25f;
|
||||
[Tooltip("Number of impact cycles")]
|
||||
public int impactCount = 2;
|
||||
|
||||
[Tooltip("Duration of pullback between thrusts")]
|
||||
public float pullbackDuration = 0.15f;
|
||||
[Tooltip("Distance weapons travel toward each other (in local units)")]
|
||||
public float thrustDistance = 35f;
|
||||
|
||||
[Tooltip("Starting position offset from midpoint (in local units)")]
|
||||
public float startOffset = 60f;
|
||||
|
||||
[Tooltip("End position offset from midpoint (in local units)")]
|
||||
public float endOffset = 15f;
|
||||
[Tooltip("Offset from center point along attack axis (in local units)")]
|
||||
public float weaponOffset = 50f;
|
||||
|
||||
[Tooltip("Base rotation offset for diagonal sprites (degrees)")]
|
||||
public float baseRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Amount of random shake (in local units)")]
|
||||
public float shakeIntensity = 12f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
private Coroutine _activeAnimation;
|
||||
|
||||
@@ -73,6 +59,10 @@ namespace Shardok {
|
||||
/// <summary>
|
||||
/// Starts a charge animation between two hex cells.
|
||||
/// </summary>
|
||||
/// <param name="attackerCellIndex">Grid index of the charging hex</param>
|
||||
/// <param name="defenderCellIndex">Grid index of the defending hex</param>
|
||||
/// <param name="attackerType">Battalion type of the attacker</param>
|
||||
/// <param name="defenderType">Battalion type of the defender</param>
|
||||
public void AnimateCharge(
|
||||
int attackerCellIndex,
|
||||
int defenderCellIndex,
|
||||
@@ -89,8 +79,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
|
||||
Vector2? attackerPos = __hexGrid.GetCellLocalPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellLocalPosition(defenderCellIndex);
|
||||
|
||||
if (attackerPos == null || defenderPos == null) {
|
||||
Debug.LogWarning(
|
||||
@@ -100,148 +90,238 @@ namespace Shardok {
|
||||
|
||||
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
|
||||
|
||||
_activeAnimation = StartCoroutine(
|
||||
AnimateChargeThrusts(attackerPos.Value, defenderPos.Value, attackerType));
|
||||
Debug.Log(
|
||||
$"ChargeAnimator: Starting charge from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
|
||||
_activeAnimation = StartCoroutine(AnimateChargeClash(
|
||||
attackerPos.Value,
|
||||
defenderPos.Value,
|
||||
attackerType,
|
||||
defenderType));
|
||||
}
|
||||
|
||||
private Sprite GetWeaponSprite(BattalionTypeId type) {
|
||||
private struct WeaponConfig {
|
||||
public Sprite sprite;
|
||||
public float scaleMultiplier;
|
||||
public float violenceMultiplier;
|
||||
}
|
||||
|
||||
private WeaponConfig GetWeaponConfig(BattalionTypeId type) {
|
||||
switch (type) {
|
||||
case BattalionTypeId.LightInfantry: return swordSprite;
|
||||
case BattalionTypeId.HeavyInfantry: return maceSprite ?? swordSprite;
|
||||
case BattalionTypeId.LightInfantry:
|
||||
return new WeaponConfig {
|
||||
sprite = swordSprite,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 1.2f
|
||||
};
|
||||
case BattalionTypeId.HeavyInfantry:
|
||||
return new WeaponConfig {
|
||||
sprite = maceSprite ?? swordSprite,
|
||||
scaleMultiplier = 1.2f,
|
||||
violenceMultiplier = 1.4f
|
||||
};
|
||||
case BattalionTypeId.LightCavalry:
|
||||
return smallSpearSprite ?? largeLanceSprite ?? swordSprite;
|
||||
return new WeaponConfig {
|
||||
sprite = smallSpearSprite ?? largeLanceSprite ?? swordSprite,
|
||||
scaleMultiplier = 1.1f,
|
||||
violenceMultiplier = 1.6f
|
||||
};
|
||||
case BattalionTypeId.HeavyCavalry:
|
||||
return largeLanceSprite ?? smallSpearSprite ?? swordSprite;
|
||||
case BattalionTypeId.Longbowmen: return daggerSprite ?? swordSprite;
|
||||
case BattalionTypeId.Undead: return boneSprite ?? swordSprite;
|
||||
default: return swordSprite;
|
||||
return new WeaponConfig {
|
||||
sprite = largeLanceSprite ?? smallSpearSprite ?? swordSprite,
|
||||
scaleMultiplier = 1.4f,
|
||||
violenceMultiplier = 2.0f
|
||||
};
|
||||
case BattalionTypeId.Longbowmen:
|
||||
return new WeaponConfig {
|
||||
sprite = daggerSprite ?? swordSprite,
|
||||
scaleMultiplier = 0.8f,
|
||||
violenceMultiplier = 1.0f
|
||||
};
|
||||
case BattalionTypeId.Undead:
|
||||
return new WeaponConfig {
|
||||
sprite = boneSprite ?? swordSprite,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 1.3f
|
||||
};
|
||||
default:
|
||||
return new WeaponConfig {
|
||||
sprite = swordSprite,
|
||||
scaleMultiplier = 1.0f,
|
||||
violenceMultiplier = 1.2f
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private float GetScaleMultiplier(BattalionTypeId type) {
|
||||
switch (type) {
|
||||
case BattalionTypeId.HeavyInfantry: return 1.2f;
|
||||
case BattalionTypeId.LightCavalry: return 1.1f;
|
||||
case BattalionTypeId.HeavyCavalry: return 1.4f;
|
||||
case BattalionTypeId.Longbowmen: return 0.8f;
|
||||
default: return 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator AnimateChargeThrusts(
|
||||
private IEnumerator AnimateChargeClash(
|
||||
Vector2 attackerPos,
|
||||
Vector2 defenderPos,
|
||||
BattalionTypeId attackerType) {
|
||||
// Calculate direction toward defender
|
||||
BattalionTypeId attackerType,
|
||||
BattalionTypeId defenderType) {
|
||||
// Calculate midpoint and direction
|
||||
Vector2 midpoint = (attackerPos + defenderPos) / 2f;
|
||||
Vector2 direction = (defenderPos - attackerPos).normalized;
|
||||
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
|
||||
|
||||
// Create attacker weapon
|
||||
Sprite weaponSprite = GetWeaponSprite(attackerType);
|
||||
float scale = weaponScale * GetScaleMultiplier(attackerType);
|
||||
// Get weapon configs
|
||||
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
|
||||
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
|
||||
|
||||
// Create weapons
|
||||
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
|
||||
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
|
||||
|
||||
float attackerViolence = shakeIntensity * attackerConfig.violenceMultiplier;
|
||||
float defenderViolence = shakeIntensity * defenderConfig.violenceMultiplier;
|
||||
|
||||
// Starting positions offset from midpoint (further back for charge buildup)
|
||||
Vector3 attackerStart = new Vector3(
|
||||
midpoint.x - direction.x * weaponOffset,
|
||||
midpoint.y - direction.y * weaponOffset,
|
||||
10f);
|
||||
Vector3 defenderStart = new Vector3(
|
||||
midpoint.x + direction.x * weaponOffset,
|
||||
midpoint.y + direction.y * weaponOffset,
|
||||
10f);
|
||||
|
||||
// Clash point (closer to midpoint)
|
||||
Vector3 attackerClash = new Vector3(
|
||||
midpoint.x - direction.x * (weaponOffset - thrustDistance),
|
||||
midpoint.y - direction.y * (weaponOffset - thrustDistance),
|
||||
10f);
|
||||
Vector3 defenderClash = new Vector3(
|
||||
midpoint.x + direction.x * (weaponOffset - thrustDistance),
|
||||
midpoint.y + direction.y * (weaponOffset - thrustDistance),
|
||||
10f);
|
||||
|
||||
attackerWeapon.transform.localPosition = attackerStart;
|
||||
defenderWeapon.transform.localPosition = defenderStart;
|
||||
|
||||
// Animate impact cycles
|
||||
for (int i = 0; i < impactCount; i++) {
|
||||
// Charge thrust toward clash point
|
||||
yield return StartCoroutine(AnimateWeaponThrust(
|
||||
attackerWeapon,
|
||||
attackerStart,
|
||||
attackerClash,
|
||||
chargeDuration / 2f,
|
||||
true,
|
||||
attackerViolence));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponThrust(
|
||||
defenderWeapon,
|
||||
defenderStart,
|
||||
defenderClash,
|
||||
chargeDuration / 2f,
|
||||
true,
|
||||
defenderViolence));
|
||||
|
||||
// Violent impact shake
|
||||
float impactViolence = Mathf.Max(attackerViolence, defenderViolence);
|
||||
yield return StartCoroutine(ImpactShake(
|
||||
attackerWeapon,
|
||||
defenderWeapon,
|
||||
attackerClash,
|
||||
defenderClash,
|
||||
0.08f,
|
||||
impactViolence));
|
||||
|
||||
// Pull back
|
||||
yield return StartCoroutine(AnimateWeaponThrust(
|
||||
attackerWeapon,
|
||||
attackerClash,
|
||||
attackerStart,
|
||||
chargeDuration / 2f,
|
||||
false,
|
||||
attackerViolence * 0.5f));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponThrust(
|
||||
defenderWeapon,
|
||||
defenderClash,
|
||||
defenderStart,
|
||||
chargeDuration / 2f,
|
||||
false,
|
||||
defenderViolence * 0.5f));
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (attackerWeapon != null) { Destroy(attackerWeapon); }
|
||||
if (defenderWeapon != null) { Destroy(defenderWeapon); }
|
||||
_activeAnimation = null;
|
||||
}
|
||||
|
||||
private GameObject CreateWeapon(WeaponConfig config, float angle) {
|
||||
GameObject weapon = new GameObject("ChargeWeapon");
|
||||
weapon.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
|
||||
SpriteRenderer renderer = weapon.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = weaponSprite;
|
||||
renderer.sprite = config.sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, baseAngle + baseRotationOffset);
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + baseRotationOffset);
|
||||
float scale = weaponScale * config.scaleMultiplier;
|
||||
weapon.transform.localScale = new Vector3(scale, scale, scale);
|
||||
|
||||
// Calculate positions
|
||||
Vector3 startPos = new Vector3(
|
||||
midpoint.x - direction.x * startOffset,
|
||||
midpoint.y - direction.y * startOffset,
|
||||
10f);
|
||||
Vector3 endPos = new Vector3(
|
||||
midpoint.x + direction.x * endOffset,
|
||||
midpoint.y + direction.y * endOffset,
|
||||
10f);
|
||||
|
||||
weapon.transform.localPosition = startPos;
|
||||
|
||||
// Animate thrusts
|
||||
for (int i = 0; i < thrustCount; i++) {
|
||||
// Thrust forward - starts slow, accelerates
|
||||
float elapsed = 0f;
|
||||
while (elapsed < thrustDuration) {
|
||||
if (weapon == null) yield break;
|
||||
|
||||
float t = elapsed / thrustDuration;
|
||||
// Cubic ease-in for accelerating thrust
|
||||
float easeT = t * t * t;
|
||||
|
||||
weapon.transform.localPosition = Vector3.Lerp(startPos, endPos, easeT);
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
weapon.transform.localPosition = endPos;
|
||||
|
||||
// Impact flash
|
||||
if (flashSprite != null) {
|
||||
yield return StartCoroutine(ShowImpactFlash(endPos, direction));
|
||||
}
|
||||
|
||||
// Pullback - smooth ease out
|
||||
elapsed = 0f;
|
||||
while (elapsed < pullbackDuration) {
|
||||
if (weapon == null) yield break;
|
||||
|
||||
float t = elapsed / pullbackDuration;
|
||||
// Ease out for smooth pullback
|
||||
float easeT = t * (2f - t);
|
||||
|
||||
weapon.transform.localPosition = Vector3.Lerp(endPos, startPos, easeT);
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
weapon.transform.localPosition = startPos;
|
||||
|
||||
// Brief pause between thrusts
|
||||
if (i < thrustCount - 1) { yield return new WaitForSeconds(0.05f); }
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (weapon != null) { Destroy(weapon); }
|
||||
_activeAnimation = null;
|
||||
return weapon;
|
||||
}
|
||||
|
||||
private IEnumerator ShowImpactFlash(Vector3 position, Vector2 direction) {
|
||||
GameObject flash = new GameObject("ChargeFlash");
|
||||
flash.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
flash.transform.localPosition = position;
|
||||
|
||||
SpriteRenderer renderer = flash.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = flashSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = flashColor;
|
||||
|
||||
private IEnumerator AnimateWeaponThrust(
|
||||
GameObject weapon,
|
||||
Vector3 fromPos,
|
||||
Vector3 toPos,
|
||||
float duration,
|
||||
bool isCharging,
|
||||
float violence) {
|
||||
float elapsed = 0f;
|
||||
while (elapsed < flashDuration) {
|
||||
float t = elapsed / flashDuration;
|
||||
|
||||
// Quick expand then fade
|
||||
float expandT = t < 0.3f ? t / 0.3f : 1f;
|
||||
float scale = flashScale * (0.5f + 0.5f * expandT);
|
||||
flash.transform.localScale = Vector3.one * scale;
|
||||
while (elapsed < duration) {
|
||||
if (weapon == null) { yield break; }
|
||||
|
||||
// Fade out
|
||||
Color c = flashColor;
|
||||
c.a = flashColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
float t = elapsed / duration;
|
||||
// More aggressive ease for charge, smoother for retreat
|
||||
float smoothT = isCharging ? t * t * t : t * (2f - t);
|
||||
|
||||
// Position with shake
|
||||
Vector3 shake = new Vector3(
|
||||
Random.Range(-violence, violence),
|
||||
Random.Range(-violence, violence),
|
||||
0f);
|
||||
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT) + shake;
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
Destroy(flash);
|
||||
if (weapon != null) { weapon.transform.localPosition = toPos; }
|
||||
}
|
||||
|
||||
private IEnumerator ImpactShake(
|
||||
GameObject weapon1,
|
||||
GameObject weapon2,
|
||||
Vector3 pos1,
|
||||
Vector3 pos2,
|
||||
float duration,
|
||||
float violence) {
|
||||
float elapsed = 0f;
|
||||
float intensity = violence * 2.5f; // Extra intense for charge impact
|
||||
|
||||
while (elapsed < duration) {
|
||||
if (weapon1 == null || weapon2 == null) { yield break; }
|
||||
|
||||
Vector3 shake1 = new Vector3(
|
||||
Random.Range(-intensity, intensity),
|
||||
Random.Range(-intensity, intensity),
|
||||
0f);
|
||||
Vector3 shake2 = new Vector3(
|
||||
Random.Range(-intensity, intensity),
|
||||
Random.Range(-intensity, intensity),
|
||||
0f);
|
||||
|
||||
weapon1.transform.localPosition = pos1 + shake1;
|
||||
weapon2.transform.localPosition = pos2 + shake2;
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+17
-40
@@ -12,13 +12,7 @@ namespace Shardok {
|
||||
public Sprite dropletSprite;
|
||||
|
||||
[Tooltip("Number of droplets")]
|
||||
public int dropletCount = 25;
|
||||
|
||||
[Tooltip("Minimum droplet scale")]
|
||||
public float dropletScaleMin = 5f;
|
||||
|
||||
[Tooltip("Maximum droplet scale")]
|
||||
public float dropletScaleMax = 15f;
|
||||
public int dropletCount = 12;
|
||||
|
||||
[Tooltip("Color of water")]
|
||||
public Color waterColor = new Color(0.3f, 0.6f, 0.9f, 0.8f);
|
||||
@@ -28,29 +22,23 @@ namespace Shardok {
|
||||
public Sprite steamSprite;
|
||||
|
||||
[Tooltip("Number of steam puffs")]
|
||||
public int steamCount = 8;
|
||||
public int steamCount = 5;
|
||||
|
||||
[Tooltip("Color of steam")]
|
||||
public Color steamColor = new Color(0.9f, 0.9f, 0.9f, 0.6f);
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Height water falls from")]
|
||||
public float fallHeight = 100f;
|
||||
public float fallHeight = 60f;
|
||||
|
||||
[Tooltip("Spread radius of the water")]
|
||||
public float waterSpread = 40f;
|
||||
public float waterSpread = 25f;
|
||||
|
||||
[Tooltip("Duration of the water fall")]
|
||||
public float fallDuration = 0.5f;
|
||||
|
||||
[Tooltip("Time spread for staggered droplet launch")]
|
||||
public float launchSpread = 0.25f;
|
||||
|
||||
[Tooltip("Horizontal chaos during fall")]
|
||||
public float fallChaos = 15f;
|
||||
public float fallDuration = 0.3f;
|
||||
|
||||
[Tooltip("Duration of the steam effect")]
|
||||
public float steamDuration = 0.5f;
|
||||
public float steamDuration = 0.4f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
@@ -84,7 +72,6 @@ namespace Shardok {
|
||||
// Create droplets
|
||||
GameObject[] droplets = new GameObject[dropletCount];
|
||||
Vector2[] dropletOffsets = new Vector2[dropletCount];
|
||||
Vector2[] dropletChaos = new Vector2[dropletCount];
|
||||
float[] dropletDelays = new float[dropletCount];
|
||||
|
||||
for (int i = 0; i < dropletCount; i++) {
|
||||
@@ -97,23 +84,17 @@ namespace Shardok {
|
||||
renderer.sprite = dropletSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = waterColor;
|
||||
droplet.transform.localScale =
|
||||
Vector3.one * Random.Range(dropletScaleMin, dropletScaleMax);
|
||||
droplet.transform.localScale = Vector3.one * Random.Range(4f, 8f);
|
||||
|
||||
float angle = Random.Range(0f, Mathf.PI * 2f);
|
||||
float radius = waterSpread * Random.Range(0f, 1f);
|
||||
dropletOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
|
||||
dropletDelays[i] = Random.Range(0f, launchSpread);
|
||||
dropletDelays[i] = Random.Range(0f, fallDuration * 0.3f);
|
||||
|
||||
// Random horizontal chaos direction for each droplet
|
||||
float chaosAngle = Random.Range(0f, Mathf.PI * 2f);
|
||||
dropletChaos[i] =
|
||||
new Vector2(Mathf.Cos(chaosAngle), Mathf.Sin(chaosAngle)) * fallChaos;
|
||||
|
||||
// Start above with some horizontal spread
|
||||
// Start above
|
||||
droplet.transform.localPosition = new Vector3(
|
||||
target.x + dropletOffsets[i].x * 0.3f,
|
||||
target.y + fallHeight + Random.Range(-10f, 10f),
|
||||
target.x + dropletOffsets[i].x,
|
||||
target.y + fallHeight + dropletOffsets[i].y * 0.5f,
|
||||
5f);
|
||||
|
||||
droplets[i] = droplet;
|
||||
@@ -121,27 +102,23 @@ namespace Shardok {
|
||||
|
||||
// Animate falling water
|
||||
float elapsed = 0f;
|
||||
float totalDuration = fallDuration + launchSpread;
|
||||
|
||||
while (elapsed < totalDuration) {
|
||||
while (elapsed < fallDuration) {
|
||||
float t = elapsed / fallDuration;
|
||||
|
||||
for (int i = 0; i < droplets.Length; i++) {
|
||||
if (droplets[i] == null) continue;
|
||||
|
||||
float dropletT = Mathf.Clamp01(
|
||||
(elapsed - dropletDelays[i]) /
|
||||
(fallDuration - dropletDelays[i] * 0.5f));
|
||||
(elapsed - dropletDelays[i]) / (fallDuration - dropletDelays[i]));
|
||||
if (dropletT <= 0) continue;
|
||||
|
||||
// Accelerating fall
|
||||
float fallT = dropletT * dropletT;
|
||||
|
||||
// Add chaotic horizontal movement (sine wave pattern)
|
||||
float chaosT = Mathf.Sin(dropletT * Mathf.PI * 2f) * (1f - dropletT);
|
||||
Vector2 chaos = dropletChaos[i] * chaosT;
|
||||
|
||||
float x = target.x + dropletOffsets[i].x + chaos.x;
|
||||
float y = target.y + fallHeight * (1f - fallT);
|
||||
droplets[i].transform.localPosition = new Vector3(x, y, 5f);
|
||||
droplets[i].transform.localPosition =
|
||||
new Vector3(target.x + dropletOffsets[i].x, y, 5f);
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
|
||||
@@ -462,14 +462,11 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetProfessionImage(int cellIndex, Texture image) {
|
||||
var professionImage = cells[cellIndex].ProfessionImage;
|
||||
if (professionImage == null) return;
|
||||
|
||||
if (image == null) {
|
||||
professionImage.gameObject.SetActive(value: false);
|
||||
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: false);
|
||||
} else {
|
||||
professionImage.texture = image;
|
||||
professionImage.gameObject.SetActive(value: true);
|
||||
cells[cellIndex].ProfessionImage.texture = image;
|
||||
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,14 +475,11 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetUnitTypeImage(int cellIndex, Texture image) {
|
||||
var unitTypeImage = cells[cellIndex].UnitTypeImage;
|
||||
if (unitTypeImage == null) return;
|
||||
|
||||
if (image == null) {
|
||||
unitTypeImage.gameObject.SetActive(value: false);
|
||||
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: false);
|
||||
} else {
|
||||
unitTypeImage.texture = image;
|
||||
unitTypeImage.gameObject.SetActive(value: true);
|
||||
cells[cellIndex].UnitTypeImage.texture = image;
|
||||
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ namespace Shardok {
|
||||
[Tooltip("Mace/hammer sprite for heavy infantry")]
|
||||
public Sprite maceSprite;
|
||||
|
||||
[Tooltip("Falchion sprite for light cavalry")]
|
||||
public Sprite falchionSprite;
|
||||
[Tooltip("Small spear sprite for light cavalry")]
|
||||
public Sprite smallSpearSprite;
|
||||
|
||||
[Tooltip("Two-handed sword sprite for heavy cavalry")]
|
||||
public Sprite twoHandedSwordSprite;
|
||||
[Tooltip("Large spear/lance sprite for heavy cavalry")]
|
||||
public Sprite largeSpearSprite;
|
||||
|
||||
[Tooltip("Dagger sprite for longbowmen")]
|
||||
public Sprite daggerSprite;
|
||||
@@ -30,57 +30,15 @@ namespace Shardok {
|
||||
[Tooltip("Bone/fist sprite for undead")]
|
||||
public Sprite boneSprite;
|
||||
|
||||
[Header("Weapon Orientation - Sword")]
|
||||
[Tooltip("Base rotation offset for sword (degrees)")]
|
||||
public float swordRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip sword horizontally")]
|
||||
public bool swordFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Mace")]
|
||||
[Tooltip("Base rotation offset for mace (degrees)")]
|
||||
public float maceRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip mace horizontally")]
|
||||
public bool maceFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Falchion")]
|
||||
[Tooltip("Base rotation offset for falchion (degrees)")]
|
||||
public float falchionRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip falchion horizontally")]
|
||||
public bool falchionFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Two-Handed Sword")]
|
||||
[Tooltip("Base rotation offset for two-handed sword (degrees)")]
|
||||
public float twoHandedSwordRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip two-handed sword horizontally")]
|
||||
public bool twoHandedSwordFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Dagger")]
|
||||
[Tooltip("Base rotation offset for dagger (degrees)")]
|
||||
public float daggerRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip dagger horizontally")]
|
||||
public bool daggerFlipHorizontal = false;
|
||||
|
||||
[Header("Weapon Orientation - Bone")]
|
||||
[Tooltip("Base rotation offset for bone (degrees)")]
|
||||
public float boneRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Flip bone horizontally")]
|
||||
public bool boneFlipHorizontal = false;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Base scale of weapon sprites")]
|
||||
public float weaponScale = 20.0f;
|
||||
|
||||
[Tooltip("Duration of a single swing cycle in seconds")]
|
||||
public float clashDuration = 0.3f;
|
||||
[Tooltip("Duration of a single clash cycle in seconds")]
|
||||
public float clashDuration = 0.12f;
|
||||
|
||||
[Tooltip("Number of swing cycles")]
|
||||
public int clashCount = 2;
|
||||
[Tooltip("Number of clash cycles")]
|
||||
public int clashCount = 3;
|
||||
|
||||
[Tooltip("Distance weapons travel toward each other (in local units)")]
|
||||
public float thrustDistance = 25f;
|
||||
@@ -88,30 +46,20 @@ namespace Shardok {
|
||||
[Tooltip("Offset from center point along attack axis (in local units)")]
|
||||
public float weaponOffset = 40f;
|
||||
|
||||
[Tooltip("Swing arc angle for swinging weapons (degrees)")]
|
||||
public float swingArc = 90f;
|
||||
[Tooltip("Base rotation offset for diagonal sprites (degrees)")]
|
||||
public float baseRotationOffset = -45f;
|
||||
|
||||
[Tooltip("Amount of shake at impact only (in local units)")]
|
||||
public float shakeIntensity = 3f;
|
||||
[Tooltip("Swing arc angle for swinging weapons (degrees)")]
|
||||
public float swingArc = 60f;
|
||||
|
||||
[Tooltip("Amount of random shake (in local units)")]
|
||||
public float shakeIntensity = 8f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
private Coroutine _activeAnimation;
|
||||
private GameObject _attackerWeapon;
|
||||
private GameObject _defenderWeapon;
|
||||
|
||||
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
|
||||
|
||||
private void CleanupWeapons() {
|
||||
if (_attackerWeapon != null) {
|
||||
Destroy(_attackerWeapon);
|
||||
_attackerWeapon = null;
|
||||
}
|
||||
if (_defenderWeapon != null) {
|
||||
Destroy(_defenderWeapon);
|
||||
_defenderWeapon = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a melee animation between two adjacent hex cells.
|
||||
/// </summary>
|
||||
@@ -124,8 +72,8 @@ namespace Shardok {
|
||||
int defenderCellIndex,
|
||||
BattalionTypeId attackerType,
|
||||
BattalionTypeId defenderType) {
|
||||
if (swordSprite == null && maceSprite == null && falchionSprite == null &&
|
||||
twoHandedSwordSprite == null && daggerSprite == null && boneSprite == null) {
|
||||
if (swordSprite == null && maceSprite == null && smallSpearSprite == null &&
|
||||
largeSpearSprite == null && daggerSprite == null && boneSprite == null) {
|
||||
Debug.LogWarning("MeleeAnimator: No weapon sprites assigned");
|
||||
return;
|
||||
}
|
||||
@@ -135,8 +83,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
|
||||
Vector2? attackerPos = __hexGrid.GetCellLocalPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellLocalPosition(defenderCellIndex);
|
||||
|
||||
if (attackerPos == null || defenderPos == null) {
|
||||
Debug.LogWarning(
|
||||
@@ -144,11 +92,10 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeAnimation != null) {
|
||||
StopCoroutine(_activeAnimation);
|
||||
CleanupWeapons();
|
||||
}
|
||||
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
|
||||
|
||||
Debug.Log(
|
||||
$"MeleeAnimator: Starting melee from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
|
||||
_activeAnimation = StartCoroutine(
|
||||
AnimateClash(attackerPos.Value, defenderPos.Value, attackerType, defenderType));
|
||||
}
|
||||
@@ -157,8 +104,7 @@ namespace Shardok {
|
||||
public Sprite sprite;
|
||||
public bool swings; // true = swing arc, false = thrust
|
||||
public float scaleMultiplier;
|
||||
public float rotationOffset;
|
||||
public bool flipHorizontal;
|
||||
public float violenceMultiplier; // shake intensity multiplier
|
||||
}
|
||||
|
||||
private WeaponConfig GetWeaponConfig(BattalionTypeId type) {
|
||||
@@ -168,66 +114,49 @@ namespace Shardok {
|
||||
sprite = swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.0f,
|
||||
rotationOffset = swordRotationOffset,
|
||||
flipHorizontal = swordFlipHorizontal
|
||||
violenceMultiplier = 0.3f
|
||||
};
|
||||
case BattalionTypeId.HeavyInfantry:
|
||||
return new WeaponConfig {
|
||||
sprite = maceSprite ?? swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.2f,
|
||||
rotationOffset =
|
||||
maceSprite != null ? maceRotationOffset : swordRotationOffset,
|
||||
flipHorizontal =
|
||||
maceSprite != null ? maceFlipHorizontal : swordFlipHorizontal
|
||||
violenceMultiplier = 0.4f
|
||||
};
|
||||
case BattalionTypeId.LightCavalry:
|
||||
return new WeaponConfig {
|
||||
sprite = falchionSprite ?? swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.0f,
|
||||
rotationOffset = falchionSprite != null ? falchionRotationOffset
|
||||
: swordRotationOffset,
|
||||
flipHorizontal = falchionSprite != null ? falchionFlipHorizontal
|
||||
: swordFlipHorizontal
|
||||
sprite = smallSpearSprite ?? largeSpearSprite ?? swordSprite,
|
||||
swings = false,
|
||||
scaleMultiplier = 0.9f,
|
||||
violenceMultiplier = 1.0f
|
||||
};
|
||||
case BattalionTypeId.HeavyCavalry:
|
||||
return new WeaponConfig {
|
||||
sprite = twoHandedSwordSprite ?? swordSprite,
|
||||
swings = true,
|
||||
sprite = largeSpearSprite ?? smallSpearSprite ?? swordSprite,
|
||||
swings = false,
|
||||
scaleMultiplier = 1.4f,
|
||||
rotationOffset = twoHandedSwordSprite != null ? twoHandedSwordRotationOffset
|
||||
: swordRotationOffset,
|
||||
flipHorizontal = twoHandedSwordSprite != null ? twoHandedSwordFlipHorizontal
|
||||
: swordFlipHorizontal
|
||||
violenceMultiplier = 1.8f
|
||||
};
|
||||
case BattalionTypeId.Longbowmen:
|
||||
return new WeaponConfig {
|
||||
sprite = daggerSprite ?? swordSprite,
|
||||
swings = false,
|
||||
scaleMultiplier = 0.7f,
|
||||
rotationOffset =
|
||||
daggerSprite != null ? daggerRotationOffset : swordRotationOffset,
|
||||
flipHorizontal =
|
||||
daggerSprite != null ? daggerFlipHorizontal : swordFlipHorizontal
|
||||
violenceMultiplier = 0.8f
|
||||
};
|
||||
case BattalionTypeId.Undead:
|
||||
return new WeaponConfig {
|
||||
sprite = boneSprite ?? swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.0f,
|
||||
rotationOffset =
|
||||
boneSprite != null ? boneRotationOffset : swordRotationOffset,
|
||||
flipHorizontal =
|
||||
boneSprite != null ? boneFlipHorizontal : swordFlipHorizontal
|
||||
violenceMultiplier = 0.5f
|
||||
};
|
||||
default:
|
||||
return new WeaponConfig {
|
||||
sprite = swordSprite,
|
||||
swings = true,
|
||||
scaleMultiplier = 1.0f,
|
||||
rotationOffset = swordRotationOffset,
|
||||
flipHorizontal = swordFlipHorizontal
|
||||
violenceMultiplier = 0.3f
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -246,12 +175,14 @@ namespace Shardok {
|
||||
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
|
||||
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
|
||||
|
||||
// Create weapons with type-specific scaling (stored in class fields for cleanup)
|
||||
_attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
|
||||
_defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
|
||||
// Create weapons with type-specific scaling
|
||||
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
|
||||
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
|
||||
|
||||
bool attackerSwings = attackerConfig.swings;
|
||||
bool defenderSwings = defenderConfig.swings;
|
||||
float attackerViolence = shakeIntensity * attackerConfig.violenceMultiplier;
|
||||
float defenderViolence = shakeIntensity * defenderConfig.violenceMultiplier;
|
||||
|
||||
// Starting positions offset from midpoint
|
||||
Vector3 attackerStart = new Vector3(
|
||||
@@ -273,71 +204,77 @@ namespace Shardok {
|
||||
midpoint.y + direction.y * (weaponOffset - thrustDistance),
|
||||
10f);
|
||||
|
||||
_attackerWeapon.transform.localPosition = attackerStart;
|
||||
_defenderWeapon.transform.localPosition = defenderStart;
|
||||
attackerWeapon.transform.localPosition = attackerStart;
|
||||
defenderWeapon.transform.localPosition = defenderStart;
|
||||
|
||||
float attackerBaseAngle = baseAngle + attackerConfig.rotationOffset;
|
||||
float defenderBaseAngle = baseAngle + 180f + defenderConfig.rotationOffset;
|
||||
float attackerBaseAngle = baseAngle + baseRotationOffset;
|
||||
float defenderBaseAngle = baseAngle + 180f + baseRotationOffset;
|
||||
|
||||
// Animate swing cycles with alternating swing directions
|
||||
// Animate clash cycles with alternating swing directions
|
||||
for (int i = 0; i < clashCount; i++) {
|
||||
// Alternate swing direction each cycle
|
||||
float swingDirection = (i % 2 == 0) ? 1f : -1f;
|
||||
|
||||
// Swing toward clash point
|
||||
// Swing/thrust toward clash point
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
_attackerWeapon,
|
||||
attackerWeapon,
|
||||
attackerStart,
|
||||
attackerClash,
|
||||
attackerBaseAngle,
|
||||
attackerSwings ? -swingArc * swingDirection : 0f,
|
||||
attackerSwings ? 0f : 0f,
|
||||
clashDuration / 2f,
|
||||
true));
|
||||
true,
|
||||
attackerViolence));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
_defenderWeapon,
|
||||
defenderWeapon,
|
||||
defenderStart,
|
||||
defenderClash,
|
||||
defenderBaseAngle,
|
||||
defenderSwings ? swingArc * swingDirection : 0f,
|
||||
defenderSwings ? 0f : 0f,
|
||||
clashDuration / 2f,
|
||||
true));
|
||||
true,
|
||||
defenderViolence));
|
||||
|
||||
// Brief shake at impact
|
||||
// Brief violent shake at impact (use max violence of both)
|
||||
float impactViolence = Mathf.Max(attackerViolence, defenderViolence);
|
||||
yield return StartCoroutine(ImpactShake(
|
||||
_attackerWeapon,
|
||||
_defenderWeapon,
|
||||
attackerWeapon,
|
||||
defenderWeapon,
|
||||
attackerClash,
|
||||
defenderClash,
|
||||
0.05f,
|
||||
shakeIntensity));
|
||||
impactViolence));
|
||||
|
||||
// Swing back
|
||||
// Swing/pull back
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
_attackerWeapon,
|
||||
attackerWeapon,
|
||||
attackerClash,
|
||||
attackerStart,
|
||||
attackerBaseAngle,
|
||||
0f,
|
||||
attackerSwings ? -swingArc * swingDirection : 0f,
|
||||
clashDuration / 2f,
|
||||
false));
|
||||
false,
|
||||
attackerViolence));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
_defenderWeapon,
|
||||
defenderWeapon,
|
||||
defenderClash,
|
||||
defenderStart,
|
||||
defenderBaseAngle,
|
||||
0f,
|
||||
defenderSwings ? swingArc * swingDirection : 0f,
|
||||
clashDuration / 2f,
|
||||
false));
|
||||
false,
|
||||
defenderViolence));
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
CleanupWeapons();
|
||||
if (attackerWeapon != null) { Destroy(attackerWeapon); }
|
||||
if (defenderWeapon != null) { Destroy(defenderWeapon); }
|
||||
_activeAnimation = null;
|
||||
}
|
||||
|
||||
@@ -349,10 +286,9 @@ namespace Shardok {
|
||||
renderer.sprite = config.sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + config.rotationOffset);
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + baseRotationOffset);
|
||||
float scale = weaponScale * config.scaleMultiplier;
|
||||
float xScale = config.flipHorizontal ? -scale : scale;
|
||||
weapon.transform.localScale = new Vector3(xScale, scale, scale);
|
||||
weapon.transform.localScale = new Vector3(scale, scale, scale);
|
||||
|
||||
return weapon;
|
||||
}
|
||||
@@ -365,20 +301,25 @@ namespace Shardok {
|
||||
float fromAngleOffset,
|
||||
float toAngleOffset,
|
||||
float duration,
|
||||
bool isAttacking) {
|
||||
bool isAttacking,
|
||||
float violence) {
|
||||
float elapsed = 0f;
|
||||
|
||||
while (elapsed < duration) {
|
||||
if (weapon == null) { yield break; }
|
||||
|
||||
float t = elapsed / duration;
|
||||
// Smooth ease for deliberate swing motion
|
||||
// Aggressive ease for attack, smoother for retreat
|
||||
float smoothT = isAttacking ? t * t * (3f - 2f * t) : t * (2f - t);
|
||||
|
||||
// Clean position interpolation - no shake during swing
|
||||
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT);
|
||||
// Position with shake (scaled by violence)
|
||||
Vector3 shake = new Vector3(
|
||||
Random.Range(-violence, violence),
|
||||
Random.Range(-violence, violence),
|
||||
0f);
|
||||
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT) + shake;
|
||||
|
||||
// Rotation arc for swinging weapons
|
||||
// Rotation (for swinging weapons)
|
||||
float currentAngle =
|
||||
Mathf.Lerp(baseAngle + fromAngleOffset, baseAngle + toAngleOffset, smoothT);
|
||||
weapon.transform.localRotation = Quaternion.Euler(0, 0, currentAngle);
|
||||
@@ -431,7 +372,6 @@ namespace Shardok {
|
||||
StopCoroutine(_activeAnimation);
|
||||
_activeAnimation = null;
|
||||
}
|
||||
CleanupWeapons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,102 +28,40 @@ namespace Shardok {
|
||||
[Tooltip("Sprite for trail particles")]
|
||||
public Sprite trailSprite;
|
||||
|
||||
[Tooltip("Number of trail particles in continuous trail")]
|
||||
public int trailParticleCount = 20;
|
||||
[Tooltip("Number of trail particles")]
|
||||
public int trailParticleCount = 8;
|
||||
|
||||
[Tooltip("Scale of trail particles")]
|
||||
public float trailScale = 50f;
|
||||
public float trailScale = 8f;
|
||||
|
||||
[Tooltip("Color of trail at front (hottest)")]
|
||||
public Color trailColorHot = new Color(1f, 0.9f, 0.6f, 0.9f);
|
||||
|
||||
[Tooltip("Color of trail at back (cooler)")]
|
||||
public Color trailColorCool = new Color(1f, 0.4f, 0.1f, 0.6f);
|
||||
|
||||
[Tooltip("Trail spread width")]
|
||||
public float trailSpread = 12f;
|
||||
[Tooltip("Color of trail")]
|
||||
public Color trailColor = new Color(1f, 0.7f, 0.3f, 0.8f);
|
||||
|
||||
[Header("Explosion Settings")]
|
||||
[Tooltip("Sprite for explosion")]
|
||||
public Sprite explosionSprite;
|
||||
|
||||
[Tooltip("Initial scale of explosion")]
|
||||
public float explosionStartScale = 20f;
|
||||
public float explosionStartScale = 15f;
|
||||
|
||||
[Tooltip("Final scale of explosion")]
|
||||
public float explosionEndScale = 250f;
|
||||
public float explosionEndScale = 50f;
|
||||
|
||||
[Tooltip("Color of explosion core")]
|
||||
public Color explosionColor = new Color(1f, 0.6f, 0.2f, 1f);
|
||||
[Tooltip("Color of explosion")]
|
||||
public Color explosionColor = new Color(1f, 0.4f, 0.1f, 1f);
|
||||
|
||||
[Tooltip("Number of fire sparks")]
|
||||
public int sparkCount = 8;
|
||||
[Tooltip("Number of sparks")]
|
||||
public int sparkCount = 6;
|
||||
|
||||
[Tooltip("Spark spread distance")]
|
||||
public float sparkSpread = 50f;
|
||||
|
||||
[Header("Debris Settings")]
|
||||
[Tooltip("Sprite for rock debris")]
|
||||
public Sprite debrisSprite;
|
||||
|
||||
[Tooltip("Number of debris rocks")]
|
||||
public int debrisCount = 6;
|
||||
|
||||
[Tooltip("Scale of debris rocks")]
|
||||
public float debrisScale = 6f;
|
||||
|
||||
[Tooltip("Debris spread distance")]
|
||||
public float debrisSpread = 60f;
|
||||
|
||||
[Tooltip("Color of debris")]
|
||||
public Color debrisColor = new Color(0.4f, 0.3f, 0.2f, 1f);
|
||||
|
||||
[Header("Meteor Rotation")]
|
||||
[Tooltip("Rotation speed in degrees per second")]
|
||||
public float rotationSpeed = 360f;
|
||||
|
||||
[Header("Charging Settings (MeteorStart)")]
|
||||
[Tooltip("Sprite for charging glow")]
|
||||
public Sprite chargeSprite;
|
||||
|
||||
[Tooltip("Color of charging effect")]
|
||||
public Color chargeColor = new Color(1f, 0.6f, 0.2f, 0.7f);
|
||||
|
||||
[Tooltip("Starting scale of charge glow")]
|
||||
public float chargeStartScale = 5f;
|
||||
|
||||
[Tooltip("Ending scale of charge glow")]
|
||||
public float chargeEndScale = 25f;
|
||||
|
||||
[Tooltip("Duration of charge animation")]
|
||||
public float chargeDuration = 0.5f;
|
||||
|
||||
[Header("Targeting Settings (MeteorTarget)")]
|
||||
[Tooltip("Sprite for target indicator")]
|
||||
public Sprite targetSprite;
|
||||
|
||||
[Tooltip("Color of target indicator")]
|
||||
public Color targetColor = new Color(1f, 0.3f, 0.1f, 0.6f);
|
||||
|
||||
[Tooltip("Scale of target indicator")]
|
||||
public float targetScale = 30f;
|
||||
|
||||
[Tooltip("Duration of target animation")]
|
||||
public float targetDuration = 0.4f;
|
||||
|
||||
[Header("Cancel Settings (MeteorCancel)")]
|
||||
[Tooltip("Color of fizzle effect")]
|
||||
public Color cancelColor = new Color(0.5f, 0.5f, 0.5f, 0.8f);
|
||||
|
||||
[Tooltip("Duration of cancel animation")]
|
||||
public float cancelDuration = 0.3f;
|
||||
public float sparkSpread = 40f;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Duration of meteor fall")]
|
||||
public float fallDuration = 0.8f;
|
||||
public float fallDuration = 0.4f;
|
||||
|
||||
[Tooltip("Duration of explosion")]
|
||||
public float explosionDuration = 0.6f;
|
||||
public float explosionDuration = 0.5f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
@@ -149,315 +87,6 @@ namespace Shardok {
|
||||
StartCoroutine(AnimateMeteor(targetPos.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Animates meteor charging effect at the caster's position.
|
||||
/// Shows a growing fiery glow gathering energy.
|
||||
/// </summary>
|
||||
public void AnimateMeteorStart(int sourceCellIndex) {
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
|
||||
if (sourcePos == null) {
|
||||
Debug.LogWarning($"MeteorAnimator: Invalid cell index {sourceCellIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(AnimateCharge(sourcePos.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Animates meteor targeting indicator at destination.
|
||||
/// Shows a pulsing circle/crosshairs marking the target.
|
||||
/// </summary>
|
||||
public void AnimateMeteorTarget(int targetCellIndex) {
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
|
||||
if (targetPos == null) {
|
||||
Debug.LogWarning($"MeteorAnimator: Invalid cell index {targetCellIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(AnimateTarget(targetPos.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Animates meteor cancel/fizzle effect at the caster's position.
|
||||
/// Shows the charging energy dissipating.
|
||||
/// </summary>
|
||||
public void AnimateMeteorCancel(int sourceCellIndex) {
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
|
||||
if (sourcePos == null) {
|
||||
Debug.LogWarning($"MeteorAnimator: Invalid cell index {sourceCellIndex}");
|
||||
return;
|
||||
}
|
||||
|
||||
StartCoroutine(AnimateCancel(sourcePos.Value));
|
||||
}
|
||||
|
||||
private IEnumerator AnimateCharge(Vector2 source) {
|
||||
Sprite sprite = chargeSprite ?? explosionSprite;
|
||||
if (sprite == null) yield break;
|
||||
|
||||
// Create charging glow
|
||||
GameObject glow = new GameObject("MeteorCharge");
|
||||
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
glow.transform.localPosition = new Vector3(source.x, source.y, 5f);
|
||||
|
||||
SpriteRenderer renderer = glow.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = chargeColor;
|
||||
|
||||
// Create rising particles
|
||||
int particleCount = 5;
|
||||
GameObject[] particles = new GameObject[particleCount];
|
||||
Vector2[] particleOffsets = new Vector2[particleCount];
|
||||
|
||||
for (int i = 0; i < particleCount; i++) {
|
||||
if (trailSprite == null) break;
|
||||
|
||||
GameObject particle = new GameObject($"ChargeParticle_{i}");
|
||||
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
|
||||
SpriteRenderer pRenderer = particle.AddComponent<SpriteRenderer>();
|
||||
pRenderer.sprite = trailSprite;
|
||||
pRenderer.sortingLayerName = "Effects";
|
||||
pRenderer.color = chargeColor;
|
||||
particle.transform.localScale = Vector3.one * trailScale;
|
||||
|
||||
float angle = (float)i / particleCount * Mathf.PI * 2f;
|
||||
particleOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * 20f;
|
||||
particles[i] = particle;
|
||||
}
|
||||
|
||||
// Animation
|
||||
float elapsed = 0f;
|
||||
while (elapsed < chargeDuration) {
|
||||
float t = elapsed / chargeDuration;
|
||||
float easeT = t * (2f - t); // Ease out
|
||||
|
||||
// Grow glow
|
||||
float scale = Mathf.Lerp(chargeStartScale, chargeEndScale, easeT);
|
||||
glow.transform.localScale = Vector3.one * scale;
|
||||
|
||||
// Pulse alpha
|
||||
Color c = chargeColor;
|
||||
c.a = chargeColor.a * (0.7f + 0.3f * Mathf.Sin(elapsed * 12f));
|
||||
renderer.color = c;
|
||||
|
||||
// Particles spiral inward and upward
|
||||
for (int i = 0; i < particles.Length; i++) {
|
||||
if (particles[i] == null) continue;
|
||||
|
||||
float particleT = (t + (float)i / particleCount) % 1f;
|
||||
float radius = 20f * (1f - particleT);
|
||||
float angle =
|
||||
particleT * Mathf.PI * 4f + (float)i / particleCount * Mathf.PI * 2f;
|
||||
Vector2 offset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
|
||||
|
||||
particles[i].transform.localPosition = new Vector3(
|
||||
source.x + offset.x,
|
||||
source.y + offset.y + particleT * 15f,
|
||||
4f);
|
||||
|
||||
var pRenderer = particles[i].GetComponent<SpriteRenderer>();
|
||||
if (pRenderer != null) {
|
||||
Color pc = chargeColor;
|
||||
pc.a = chargeColor.a * (1f - particleT * 0.5f);
|
||||
pRenderer.color = pc;
|
||||
}
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Brief flash at end
|
||||
glow.transform.localScale = Vector3.one * chargeEndScale * 1.2f;
|
||||
Color flashColor = chargeColor;
|
||||
flashColor.a = 1f;
|
||||
renderer.color = flashColor;
|
||||
yield return new WaitForSeconds(0.05f);
|
||||
|
||||
// Cleanup
|
||||
Destroy(glow);
|
||||
foreach (var p in particles) {
|
||||
if (p != null) Destroy(p);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator AnimateTarget(Vector2 target) {
|
||||
Sprite sprite = targetSprite ?? explosionSprite;
|
||||
if (sprite == null) yield break;
|
||||
|
||||
// Create target indicator
|
||||
GameObject indicator = new GameObject("MeteorTarget");
|
||||
indicator.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
indicator.transform.localPosition = new Vector3(target.x, target.y, 5f);
|
||||
|
||||
SpriteRenderer renderer = indicator.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = targetColor;
|
||||
|
||||
// Create outer ring that contracts
|
||||
GameObject ring = null;
|
||||
SpriteRenderer ringRenderer = null;
|
||||
if (sprite != null) {
|
||||
ring = new GameObject("TargetRing");
|
||||
ring.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
ring.transform.localPosition = new Vector3(target.x, target.y, 6f);
|
||||
|
||||
ringRenderer = ring.AddComponent<SpriteRenderer>();
|
||||
ringRenderer.sprite = sprite;
|
||||
ringRenderer.sortingLayerName = "Effects";
|
||||
Color ringColor = targetColor;
|
||||
ringColor.a = targetColor.a * 0.5f;
|
||||
ringRenderer.color = ringColor;
|
||||
}
|
||||
|
||||
// Animation - indicator appears, ring contracts to it
|
||||
float elapsed = 0f;
|
||||
while (elapsed < targetDuration) {
|
||||
float t = elapsed / targetDuration;
|
||||
|
||||
// Main indicator pulses
|
||||
float pulse = 0.8f + 0.2f * Mathf.Sin(elapsed * 15f);
|
||||
indicator.transform.localScale = Vector3.one * targetScale * pulse;
|
||||
|
||||
Color c = targetColor;
|
||||
c.a = targetColor.a * pulse;
|
||||
renderer.color = c;
|
||||
|
||||
// Ring contracts inward
|
||||
if (ring != null) {
|
||||
float ringScale = targetScale * (2f - t);
|
||||
ring.transform.localScale = Vector3.one * ringScale;
|
||||
|
||||
Color rc = targetColor;
|
||||
rc.a = targetColor.a * 0.5f * (1f - t);
|
||||
ringRenderer.color = rc;
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Final pulse and fade
|
||||
float fadeDuration = 0.15f;
|
||||
elapsed = 0f;
|
||||
while (elapsed < fadeDuration) {
|
||||
float t = elapsed / fadeDuration;
|
||||
|
||||
indicator.transform.localScale = Vector3.one * targetScale * (1f + t * 0.3f);
|
||||
|
||||
Color c = targetColor;
|
||||
c.a = targetColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
Destroy(indicator);
|
||||
if (ring != null) Destroy(ring);
|
||||
}
|
||||
|
||||
private IEnumerator AnimateCancel(Vector2 source) {
|
||||
Sprite sprite = chargeSprite ?? explosionSprite;
|
||||
if (sprite == null) yield break;
|
||||
|
||||
// Create fizzling glow (starts at charge end size)
|
||||
GameObject glow = new GameObject("MeteorCancel");
|
||||
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
glow.transform.localPosition = new Vector3(source.x, source.y, 5f);
|
||||
glow.transform.localScale = Vector3.one * chargeEndScale;
|
||||
|
||||
SpriteRenderer renderer = glow.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = cancelColor;
|
||||
|
||||
// Create dispersing particles
|
||||
int particleCount = 6;
|
||||
GameObject[] particles = new GameObject[particleCount];
|
||||
Vector2[] directions = new Vector2[particleCount];
|
||||
|
||||
for (int i = 0; i < particleCount; i++) {
|
||||
if (trailSprite == null) break;
|
||||
|
||||
GameObject particle = new GameObject($"CancelParticle_{i}");
|
||||
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
particle.transform.localPosition = new Vector3(source.x, source.y, 4f);
|
||||
|
||||
SpriteRenderer pRenderer = particle.AddComponent<SpriteRenderer>();
|
||||
pRenderer.sprite = trailSprite;
|
||||
pRenderer.sortingLayerName = "Effects";
|
||||
pRenderer.color = cancelColor;
|
||||
particle.transform.localScale = Vector3.one * trailScale;
|
||||
|
||||
float angle = (float)i / particleCount * Mathf.PI * 2f + Random.Range(-0.2f, 0.2f);
|
||||
directions[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
|
||||
particles[i] = particle;
|
||||
}
|
||||
|
||||
// Animation - shrink and fade while particles disperse
|
||||
float elapsed = 0f;
|
||||
while (elapsed < cancelDuration) {
|
||||
float t = elapsed / cancelDuration;
|
||||
|
||||
// Shrink and fade glow
|
||||
float scale = chargeEndScale * (1f - t * 0.7f);
|
||||
glow.transform.localScale = Vector3.one * scale;
|
||||
|
||||
Color c = cancelColor;
|
||||
c.a = cancelColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
|
||||
// Particles drift outward and fade
|
||||
for (int i = 0; i < particles.Length; i++) {
|
||||
if (particles[i] == null) continue;
|
||||
|
||||
Vector2 pos = source + directions[i] * 30f * t;
|
||||
particles[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
|
||||
|
||||
var pRenderer = particles[i].GetComponent<SpriteRenderer>();
|
||||
if (pRenderer != null) {
|
||||
Color pc = cancelColor;
|
||||
pc.a = cancelColor.a * (1f - t);
|
||||
pRenderer.color = pc;
|
||||
particles[i].transform.localScale =
|
||||
Vector3.one * trailScale * (1f - t * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
Destroy(glow);
|
||||
foreach (var p in particles) {
|
||||
if (p != null) Destroy(p);
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator AnimateMeteor(Vector2 target) {
|
||||
List<GameObject> objects = new List<GameObject>();
|
||||
|
||||
@@ -466,24 +95,25 @@ namespace Shardok {
|
||||
Vector2 fallDirection = new Vector2(Mathf.Sin(angleRad), Mathf.Cos(angleRad));
|
||||
Vector2 startPos = target + fallDirection * fallDistance;
|
||||
|
||||
// Create meteor rock
|
||||
// Create meteor
|
||||
GameObject meteor = null;
|
||||
SpriteRenderer meteorRenderer = null;
|
||||
float currentRotation = 0f;
|
||||
if (meteorSprite != null) {
|
||||
meteor = new GameObject("Meteor");
|
||||
meteor.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
|
||||
meteorRenderer = meteor.AddComponent<SpriteRenderer>();
|
||||
SpriteRenderer meteorRenderer = meteor.AddComponent<SpriteRenderer>();
|
||||
meteorRenderer.sprite = meteorSprite;
|
||||
meteorRenderer.sortingLayerName = "Effects";
|
||||
meteorRenderer.color = meteorColor;
|
||||
meteor.transform.localScale = Vector3.one * meteorScale;
|
||||
meteor.transform.localPosition = new Vector3(startPos.x, startPos.y, 3f);
|
||||
|
||||
// Rotate meteor to face direction of travel
|
||||
float rotAngle = Mathf.Atan2(-fallDirection.x, -fallDirection.y) * Mathf.Rad2Deg;
|
||||
meteor.transform.localRotation = Quaternion.Euler(0, 0, rotAngle);
|
||||
objects.Add(meteor);
|
||||
}
|
||||
|
||||
// Pre-create trail particles (continuous trail behind meteor)
|
||||
// Trail particles
|
||||
List<TrailParticle> trailParticles = new List<TrailParticle>();
|
||||
for (int i = 0; i < trailParticleCount; i++) {
|
||||
if (trailSprite == null) break;
|
||||
@@ -494,75 +124,58 @@ namespace Shardok {
|
||||
SpriteRenderer renderer = particle.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = trailSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
particle.transform.localPosition = new Vector3(startPos.x, startPos.y, 4f);
|
||||
|
||||
// Particles further back are smaller and cooler colored
|
||||
float trailT = (float)i / trailParticleCount;
|
||||
float particleScale = trailScale * (1f - trailT * 0.6f);
|
||||
particle.transform.localScale = Vector3.one * particleScale;
|
||||
|
||||
Color particleColor = Color.Lerp(trailColorHot, trailColorCool, trailT);
|
||||
renderer.color = particleColor;
|
||||
|
||||
// Random offset perpendicular to fall direction
|
||||
float perpOffset = Random.Range(-trailSpread, trailSpread) * trailT;
|
||||
renderer.color = trailColor;
|
||||
particle.transform.localScale = Vector3.one * trailScale;
|
||||
particle.SetActive(false);
|
||||
|
||||
trailParticles.Add(new TrailParticle {
|
||||
gameObject = particle,
|
||||
delay = trailT * 0.3f, // How far behind the meteor this particle trails
|
||||
offset = perpOffset
|
||||
delay = (float)i / trailParticleCount * 0.15f,
|
||||
offset = Random.Range(-5f, 5f)
|
||||
});
|
||||
objects.Add(particle);
|
||||
}
|
||||
|
||||
// Fall phase with rotation and continuous trail
|
||||
// Fall phase
|
||||
float elapsed = 0f;
|
||||
int nextTrailIndex = 0;
|
||||
|
||||
while (elapsed < fallDuration) {
|
||||
float t = elapsed / fallDuration;
|
||||
// Ease-in for accelerating fall (starts slow, speeds up)
|
||||
float easeT = t * t;
|
||||
float easeT = t * t; // Accelerating fall
|
||||
|
||||
Vector2 currentPos = Vector2.Lerp(startPos, target, easeT);
|
||||
|
||||
// Update meteor position and rotation
|
||||
if (meteor != null) {
|
||||
meteor.transform.localPosition = new Vector3(currentPos.x, currentPos.y, 3f);
|
||||
|
||||
// Continuous rotation
|
||||
currentRotation += rotationSpeed * Time.deltaTime;
|
||||
meteor.transform.localRotation = Quaternion.Euler(0, 0, currentRotation);
|
||||
|
||||
// Slight glow intensification as it approaches
|
||||
Color c = meteorColor;
|
||||
c.r = Mathf.Min(1f, meteorColor.r + t * 0.2f);
|
||||
c.g = Mathf.Min(1f, meteorColor.g + t * 0.1f);
|
||||
meteorRenderer.color = c;
|
||||
}
|
||||
|
||||
// Update trail particles to follow behind meteor
|
||||
Vector2 perpDir = new Vector2(-fallDirection.y, fallDirection.x);
|
||||
for (int i = 0; i < trailParticles.Count; i++) {
|
||||
var tp = trailParticles[i];
|
||||
if (tp.gameObject == null) continue;
|
||||
// Spawn trail particles
|
||||
while (nextTrailIndex < trailParticles.Count &&
|
||||
elapsed >= trailParticles[nextTrailIndex].delay) {
|
||||
var tp = trailParticles[nextTrailIndex];
|
||||
tp.gameObject.SetActive(true);
|
||||
tp.spawnPosition =
|
||||
currentPos + new Vector2(tp.offset, tp.offset * 0.5f) * fallDirection.x;
|
||||
tp.gameObject.transform.localPosition =
|
||||
new Vector3(tp.spawnPosition.x, tp.spawnPosition.y, 4f);
|
||||
nextTrailIndex++;
|
||||
}
|
||||
|
||||
// Calculate position along the trail (behind the meteor)
|
||||
float trailT = (float)i / trailParticleCount;
|
||||
float trailDistance = trailT * 50f; // How far behind
|
||||
|
||||
// Position is behind meteor along fall direction
|
||||
Vector2 trailPos = currentPos + fallDirection * trailDistance;
|
||||
// Add perpendicular wobble
|
||||
trailPos += perpDir * tp.offset * Mathf.Sin(elapsed * 8f + i);
|
||||
|
||||
tp.gameObject.transform.localPosition = new Vector3(trailPos.x, trailPos.y, 4f);
|
||||
|
||||
// Flicker effect
|
||||
var renderer = tp.gameObject.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color baseColor = Color.Lerp(trailColorHot, trailColorCool, trailT);
|
||||
baseColor.a *= 0.8f + 0.2f * Mathf.Sin(elapsed * 15f + i * 0.5f);
|
||||
renderer.color = baseColor;
|
||||
// Fade trail particles
|
||||
foreach (var tp in trailParticles) {
|
||||
if (!tp.gameObject.activeSelf) continue;
|
||||
float age = elapsed - tp.delay;
|
||||
if (age > 0) {
|
||||
float fadeT = Mathf.Clamp01(age / 0.2f);
|
||||
var renderer = tp.gameObject.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = trailColor;
|
||||
c.a = trailColor.a * (1f - fadeT);
|
||||
renderer.color = c;
|
||||
tp.gameObject.transform.localScale =
|
||||
Vector3.one * trailScale * (1f - fadeT * 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +190,7 @@ namespace Shardok {
|
||||
}
|
||||
objects.Clear();
|
||||
|
||||
// Explosion phase - bigger and with debris
|
||||
// Explosion phase
|
||||
GameObject explosion = null;
|
||||
SpriteRenderer explosionRenderer = null;
|
||||
|
||||
@@ -590,11 +203,10 @@ namespace Shardok {
|
||||
explosionRenderer.sprite = explosionSprite;
|
||||
explosionRenderer.sortingLayerName = "Effects";
|
||||
explosionRenderer.color = explosionColor;
|
||||
explosion.transform.localScale = Vector3.one * explosionStartScale;
|
||||
objects.Add(explosion);
|
||||
}
|
||||
|
||||
// Fire sparks (fast moving, fiery)
|
||||
// Sparks
|
||||
List<SparkParticle> sparks = new List<SparkParticle>();
|
||||
for (int i = 0; i < sparkCount; i++) {
|
||||
if (trailSprite == null) break;
|
||||
@@ -613,61 +225,27 @@ namespace Shardok {
|
||||
sparks.Add(new SparkParticle {
|
||||
gameObject = spark,
|
||||
direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)),
|
||||
speed = sparkSpread* Random.Range(0.8f, 1.4f)
|
||||
speed = sparkSpread* Random.Range(0.7f, 1.3f)
|
||||
});
|
||||
objects.Add(spark);
|
||||
}
|
||||
|
||||
// Rock debris (slower, with gravity arc, rotating)
|
||||
List<DebrisParticle> debris = new List<DebrisParticle>();
|
||||
Sprite actualDebrisSprite = debrisSprite ?? meteorSprite;
|
||||
if (actualDebrisSprite != null) {
|
||||
for (int i = 0; i < debrisCount; i++) {
|
||||
GameObject rock = new GameObject($"Debris_{i}");
|
||||
rock.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
rock.transform.localPosition = new Vector3(target.x, target.y, 3f);
|
||||
|
||||
SpriteRenderer renderer = rock.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = actualDebrisSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = debrisColor;
|
||||
|
||||
float randomScale = debrisScale * Random.Range(0.6f, 1.4f);
|
||||
rock.transform.localScale = Vector3.one * randomScale;
|
||||
|
||||
// Debris flies outward in all directions
|
||||
float angle =
|
||||
(float)i / debrisCount * Mathf.PI * 2f + Random.Range(-0.4f, 0.4f);
|
||||
Vector2 dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
|
||||
|
||||
debris.Add(new DebrisParticle {
|
||||
gameObject = rock,
|
||||
direction = dir,
|
||||
speed = debrisSpread * Random.Range(0.6f, 1.2f),
|
||||
rotationSpeed = Random.Range(-400f, 400f),
|
||||
currentRotation = Random.Range(0f, 360f)
|
||||
});
|
||||
objects.Add(rock);
|
||||
}
|
||||
}
|
||||
|
||||
// Explosion animation
|
||||
elapsed = 0f;
|
||||
while (elapsed < explosionDuration) {
|
||||
float t = elapsed / explosionDuration;
|
||||
|
||||
// Expand and fade explosion with initial flash
|
||||
// Expand and fade explosion
|
||||
if (explosion != null) {
|
||||
float flashT = t < 0.1f ? 1f + (0.1f - t) * 5f : 1f;
|
||||
float scale = Mathf.Lerp(explosionStartScale, explosionEndScale, t) * flashT;
|
||||
float scale = Mathf.Lerp(explosionStartScale, explosionEndScale, t);
|
||||
explosion.transform.localScale = Vector3.one * scale;
|
||||
|
||||
Color c = explosionColor;
|
||||
c.a = (1f - t * t); // Slower fade at start
|
||||
c.a = 1f - t;
|
||||
explosionRenderer.color = c;
|
||||
}
|
||||
|
||||
// Animate fire sparks (fast, straight lines)
|
||||
// Animate sparks
|
||||
foreach (var spark in sparks) {
|
||||
if (spark.gameObject == null) continue;
|
||||
|
||||
@@ -680,31 +258,7 @@ namespace Shardok {
|
||||
c.a = 1f - t;
|
||||
renderer.color = c;
|
||||
spark.gameObject.transform.localScale =
|
||||
Vector3.one * trailScale * 1.5f * (1f - t * 0.6f);
|
||||
}
|
||||
}
|
||||
|
||||
// Animate debris (flies outward in all directions, rotating)
|
||||
for (int di = 0; di < debris.Count; di++) {
|
||||
var d = debris[di];
|
||||
if (d.gameObject == null) continue;
|
||||
|
||||
// Debris flies outward, slowing down over time
|
||||
float easeT = t * (2f - t); // Ease out - fast start, slow end
|
||||
Vector2 pos = target + d.direction * d.speed * easeT;
|
||||
d.gameObject.transform.localPosition = new Vector3(pos.x, pos.y, 3f);
|
||||
|
||||
// Spin the debris
|
||||
d.currentRotation += d.rotationSpeed * Time.deltaTime;
|
||||
d.gameObject.transform.localRotation =
|
||||
Quaternion.Euler(0, 0, d.currentRotation);
|
||||
debris[di] = d; // Write back modified struct
|
||||
|
||||
var renderer = d.gameObject.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = debrisColor;
|
||||
c.a = 1f - t * 0.8f; // Debris fades slower
|
||||
renderer.color = c;
|
||||
Vector3.one * trailScale * 1.5f * (1f - t * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,13 +284,5 @@ namespace Shardok {
|
||||
public Vector2 direction;
|
||||
public float speed;
|
||||
}
|
||||
|
||||
private struct DebrisParticle {
|
||||
public GameObject gameObject;
|
||||
public Vector2 direction;
|
||||
public float speed;
|
||||
public float rotationSpeed;
|
||||
public float currentRotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,41 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using static Net.Eagle0.Shardok.Api.BattalionView.Types;
|
||||
|
||||
namespace Shardok {
|
||||
/// <summary>
|
||||
/// Animates unit movement between hex cells using a trail of footprints/hoofprints.
|
||||
/// Boot prints for infantry, horseshoe prints for cavalry.
|
||||
/// Prints appear sequentially then fade out FIFO.
|
||||
/// Animates unit movement from source to target hex with smooth interpolation.
|
||||
/// Creates a temporary indicator that slides across the grid.
|
||||
/// </summary>
|
||||
public class MoveAnimator : MonoBehaviour {
|
||||
[Header("Print Sprites")]
|
||||
[Tooltip("Boot print sprite (will be flipped for left/right)")]
|
||||
public Sprite bootPrintSprite;
|
||||
|
||||
[Tooltip("Paired horseshoe prints sprite")]
|
||||
public Sprite hoofPrintSprite;
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Number of prints between hex centers")]
|
||||
public int printsPerHex = 5;
|
||||
[Tooltip("Duration of the movement in seconds")]
|
||||
public float moveDuration = 0.3f;
|
||||
|
||||
[Tooltip("Time between each print appearing (seconds)")]
|
||||
public float printInterval = 0.06f;
|
||||
[Tooltip("Optional sprite to show during movement")]
|
||||
public Sprite moveIndicatorSprite;
|
||||
|
||||
[Tooltip("How long each print stays fully visible before fading (seconds)")]
|
||||
public float printLingerTime = 0.3f;
|
||||
[Tooltip("Scale of the movement indicator")]
|
||||
public float indicatorScale = 15f;
|
||||
|
||||
[Tooltip("Duration of each print's fade out (seconds)")]
|
||||
public float fadeDuration = 0.4f;
|
||||
[Tooltip("Height offset for arc movement (0 = straight line)")]
|
||||
public float arcHeight = 0.05f;
|
||||
|
||||
[Tooltip("Scale of print sprites")]
|
||||
public float printScale = 1.5f;
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
[Tooltip("Lateral offset for alternating prints")]
|
||||
public float lateralOffset = 6f;
|
||||
|
||||
[Tooltip("Color tint for boot prints")]
|
||||
public Color bootPrintColor = new Color(0.3f, 0.2f, 0.1f, 0.8f);
|
||||
|
||||
[Tooltip("Color tint for hoof prints")]
|
||||
public Color hoofPrintColor = new Color(0.25f, 0.15f, 0.05f, 0.8f);
|
||||
|
||||
private HexGrid _hexGrid;
|
||||
private readonly List<Coroutine> _activeCoroutines = new List<Coroutine>();
|
||||
|
||||
private void Start() { _hexGrid = FindObjectOfType<HexGrid>(); }
|
||||
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
|
||||
|
||||
/// <summary>
|
||||
/// Animate movement from source to target hex.
|
||||
/// Starts a move animation between two hex cells.
|
||||
/// Multiple animations can run simultaneously.
|
||||
/// </summary>
|
||||
/// <param name="sourceCellIndex">Starting hex cell index</param>
|
||||
/// <param name="targetCellIndex">Destination hex cell index</param>
|
||||
/// <param name="unitType">Type of battalion (determines boot vs hoof prints)</param>
|
||||
public void
|
||||
AnimateMove(int sourceCellIndex, int targetCellIndex, BattalionTypeId unitType) {
|
||||
if (bootPrintSprite == null && hoofPrintSprite == null) {
|
||||
Debug.LogWarning("MoveAnimator: No print sprites assigned");
|
||||
public void AnimateMove(int sourceCellIndex, int targetCellIndex) {
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MoveAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hexGrid == null || _hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("MoveAnimator: No HexGrid or gridCanvas available");
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
Debug.LogWarning(
|
||||
@@ -74,122 +43,45 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
bool isMounted = unitType == BattalionTypeId.LightCavalry ||
|
||||
unitType == BattalionTypeId.HeavyCavalry;
|
||||
|
||||
StartCoroutine(SpawnPrintTrail(sourcePos.Value, targetPos.Value, isMounted));
|
||||
StartCoroutine(AnimateMovement(sourcePos.Value, targetPos.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload for when unit type is not available - defaults to boot prints.
|
||||
/// </summary>
|
||||
public void AnimateMove(int sourceCellIndex, int targetCellIndex) {
|
||||
AnimateMove(sourceCellIndex, targetCellIndex, BattalionTypeId.LightInfantry);
|
||||
}
|
||||
private IEnumerator AnimateMovement(Vector2 source, Vector2 target) {
|
||||
// Create movement indicator if sprite assigned
|
||||
GameObject indicator = null;
|
||||
if (moveIndicatorSprite != null) {
|
||||
indicator = new GameObject("MoveIndicator");
|
||||
indicator.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
|
||||
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
|
||||
var prints = new List<GameObject>();
|
||||
Vector2 direction = (target - source).normalized;
|
||||
float totalDistance = Vector2.Distance(source, target);
|
||||
|
||||
// Calculate rotation angle for prints to face direction of travel
|
||||
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
|
||||
|
||||
Sprite printSprite = isMounted ? (hoofPrintSprite ?? bootPrintSprite)
|
||||
: (bootPrintSprite ?? hoofPrintSprite);
|
||||
Color printColor = isMounted ? hoofPrintColor : bootPrintColor;
|
||||
|
||||
// Spawn prints along the path
|
||||
for (int i = 0; i < printsPerHex; i++) {
|
||||
float t = (i + 1f) / (printsPerHex + 1f);
|
||||
Vector2 position = Vector2.Lerp(source, target, t);
|
||||
|
||||
// Alternate left/right for boot prints (flip horizontally)
|
||||
bool flipX = !isMounted && (i % 2 == 1);
|
||||
|
||||
// Lateral offset for alternating prints (feet or front/rear hooves)
|
||||
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
|
||||
float offset = (i % 2 == 0) ? -lateralOffset : lateralOffset;
|
||||
position += perpendicular * offset;
|
||||
|
||||
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
|
||||
prints.Add(print);
|
||||
|
||||
// Start FIFO fade coroutine for this print
|
||||
var fadeCoroutine =
|
||||
StartCoroutine(FadePrintFIFO(print, printLingerTime + i * printInterval));
|
||||
_activeCoroutines.Add(fadeCoroutine);
|
||||
|
||||
yield return new WaitForSeconds(printInterval);
|
||||
var renderer = indicator.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = moveIndicatorSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
indicator.transform.localScale = Vector3.one * indicatorScale;
|
||||
}
|
||||
|
||||
// Wait for all fades to complete
|
||||
float totalWaitTime = printLingerTime + fadeDuration + 0.1f;
|
||||
yield return new WaitForSeconds(totalWaitTime);
|
||||
|
||||
// Cleanup any remaining prints
|
||||
foreach (var print in prints) {
|
||||
if (print != null) { Destroy(print); }
|
||||
}
|
||||
|
||||
_activeCoroutines.Clear();
|
||||
}
|
||||
|
||||
private GameObject
|
||||
CreatePrint(Vector2 position, float angle, Sprite sprite, Color color, bool flipX) {
|
||||
GameObject print = new GameObject("Footprint");
|
||||
print.transform.SetParent(_hexGrid.gridCanvas.transform, false);
|
||||
|
||||
SpriteRenderer renderer = print.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = sprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.sortingOrder = -1; // Below other effects
|
||||
renderer.color = color;
|
||||
|
||||
print.transform.localPosition = new Vector3(position.x, position.y, 0);
|
||||
print.transform.localRotation = Quaternion.Euler(0, 0, angle);
|
||||
|
||||
Vector3 scale = new Vector3(flipX ? -printScale : printScale, printScale, printScale);
|
||||
print.transform.localScale = scale;
|
||||
|
||||
return print;
|
||||
}
|
||||
|
||||
private IEnumerator FadePrintFIFO(GameObject print, float delay) {
|
||||
// Wait before starting fade (FIFO - earlier prints fade first)
|
||||
yield return new WaitForSeconds(delay);
|
||||
|
||||
if (print == null) yield break;
|
||||
|
||||
SpriteRenderer renderer = print.GetComponent<SpriteRenderer>();
|
||||
if (renderer == null) yield break;
|
||||
|
||||
Color startColor = renderer.color;
|
||||
float elapsed = 0f;
|
||||
float distance = Vector2.Distance(source, target);
|
||||
|
||||
while (elapsed < fadeDuration) {
|
||||
if (print == null || renderer == null) yield break;
|
||||
while (elapsed < moveDuration) {
|
||||
float t = elapsed / moveDuration;
|
||||
float smoothT = t * t * (3f - 2f * t); // Smooth step
|
||||
|
||||
float t = elapsed / fadeDuration;
|
||||
Color newColor = startColor;
|
||||
newColor.a = Mathf.Lerp(startColor.a, 0f, t);
|
||||
renderer.color = newColor;
|
||||
Vector3 pos = Vector3.Lerp(
|
||||
new Vector3(source.x, source.y, 5f),
|
||||
new Vector3(target.x, target.y, 5f),
|
||||
smoothT);
|
||||
|
||||
// Add slight arc on Y axis for visible height in top-down view
|
||||
float arc = 4f * arcHeight * distance * t * (1f - t);
|
||||
pos.y += arc;
|
||||
|
||||
if (indicator != null) { indicator.transform.localPosition = pos; }
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
if (print != null) { Destroy(print); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels all active print animations.
|
||||
/// </summary>
|
||||
public void CancelAllAnimations() {
|
||||
foreach (var coroutine in _activeCoroutines) {
|
||||
if (coroutine != null) { StopCoroutine(coroutine); }
|
||||
}
|
||||
_activeCoroutines.Clear();
|
||||
if (indicator != null) { Destroy(indicator); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ using UnityEngine;
|
||||
|
||||
namespace Shardok {
|
||||
/// <summary>
|
||||
/// Animates scouting action with an eye at source, a vision cone extending
|
||||
/// toward target, and a glow that travels with the cone tip and grows to
|
||||
/// cover the target hex and its neighbors.
|
||||
/// Animates scouting action with an eye/binoculars effect and scanning beam.
|
||||
/// Creates a sweeping vision cone from source toward target.
|
||||
/// </summary>
|
||||
public class ScoutAnimator : MonoBehaviour {
|
||||
[Header("Eye Settings")]
|
||||
@@ -19,40 +18,34 @@ namespace Shardok {
|
||||
public float eyeScale = 15f;
|
||||
|
||||
[Header("Vision Cone Settings")]
|
||||
[Tooltip("Sprite for vision cone/beam (triangle pointing right)")]
|
||||
[Tooltip("Sprite for vision cone/beam")]
|
||||
public Sprite coneSprite;
|
||||
|
||||
[Tooltip("Color of the vision cone")]
|
||||
public Color coneColor = new Color(1f, 1f, 0.5f, 0.3f);
|
||||
|
||||
[Tooltip("Width of the vision cone at base")]
|
||||
public float coneWidth = 30f;
|
||||
[Tooltip("Length of the vision cone")]
|
||||
public float coneLength = 60f;
|
||||
|
||||
[Tooltip("Rotation offset for cone sprite (degrees). +90 for upward-pointing triangle")]
|
||||
public float coneRotationOffset = 90f;
|
||||
[Tooltip("Width of the vision cone")]
|
||||
public float coneWidth = 40f;
|
||||
|
||||
[Header("Glow Settings")]
|
||||
[Tooltip("Sprite for the traveling glow (circle)")]
|
||||
public Sprite glowSprite;
|
||||
[Header("Scan Lines Settings")]
|
||||
[Tooltip("Sprite for scan line particles")]
|
||||
public Sprite scanSprite;
|
||||
|
||||
[Tooltip("Color of the glow")]
|
||||
public Color glowColor = new Color(1f, 1f, 0.7f, 0.5f);
|
||||
[Tooltip("Number of scan lines")]
|
||||
public int scanLineCount = 3;
|
||||
|
||||
[Tooltip("Starting scale of glow (at source)")]
|
||||
public float glowStartScale = 10f;
|
||||
|
||||
[Tooltip("Final scale of glow in hex radii (2.5 = covers 7-hex area)")]
|
||||
public float glowEndRadii = 2.5f;
|
||||
[Tooltip("Color of scan lines")]
|
||||
public Color scanColor = new Color(1f, 1f, 0.8f, 0.6f);
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[Tooltip("Duration of cone extension")]
|
||||
public float extendDuration = 0.5f;
|
||||
[Tooltip("Duration of the scan sweep")]
|
||||
public float scanDuration = 0.6f;
|
||||
|
||||
[Tooltip("Duration the glow holds at target")]
|
||||
public float holdDuration = 0.4f;
|
||||
|
||||
[Tooltip("Duration of fade out")]
|
||||
public float fadeDuration = 0.3f;
|
||||
[Tooltip("Sweep angle in degrees")]
|
||||
public float sweepAngle = 30f;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
@@ -62,6 +55,11 @@ namespace Shardok {
|
||||
/// Starts a scout animation from source toward target hex.
|
||||
/// </summary>
|
||||
public void AnimateScout(int sourceCellIndex, int targetCellIndex) {
|
||||
if (eyeSprite == null && coneSprite == null) {
|
||||
Debug.LogWarning("ScoutAnimator: No sprites assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
|
||||
Debug.LogWarning("ScoutAnimator: No _hexGrid or gridCanvas available");
|
||||
return;
|
||||
@@ -76,62 +74,59 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
float hexRadius = __hexGrid.GetHexInnerRadius();
|
||||
float glowEndScale = hexRadius * glowEndRadii * 2f;
|
||||
|
||||
StartCoroutine(AnimateScoutEffect(sourcePos.Value, targetPos.Value, glowEndScale));
|
||||
StartCoroutine(AnimateScoutEffect(sourcePos.Value, targetPos.Value));
|
||||
}
|
||||
|
||||
private IEnumerator AnimateScoutEffect(Vector2 source, Vector2 target, float glowEndScale) {
|
||||
private IEnumerator AnimateScoutEffect(Vector2 source, Vector2 target) {
|
||||
Vector2 direction = (target - source).normalized;
|
||||
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
|
||||
float distance = Vector2.Distance(source, target);
|
||||
|
||||
// Create eye at source
|
||||
GameObject eye = null;
|
||||
SpriteRenderer eyeRenderer = null;
|
||||
if (eyeSprite != null) {
|
||||
eye = new GameObject("ScoutEye");
|
||||
eye.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
eye.transform.localPosition = new Vector3(source.x, source.y, 5f);
|
||||
eye.transform.localScale = Vector3.zero;
|
||||
|
||||
eyeRenderer = eye.AddComponent<SpriteRenderer>();
|
||||
eyeRenderer.sprite = eyeSprite;
|
||||
eyeRenderer.sortingLayerName = "Effects";
|
||||
eyeRenderer.color = eyeColor;
|
||||
SpriteRenderer renderer = eye.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = eyeSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = eyeColor;
|
||||
}
|
||||
|
||||
// Create vision cone
|
||||
GameObject cone = null;
|
||||
SpriteRenderer coneRenderer = null;
|
||||
if (coneSprite != null) {
|
||||
cone = new GameObject("VisionCone");
|
||||
cone.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
cone.transform.localPosition = new Vector3(source.x, source.y, 6f);
|
||||
cone.transform.localRotation =
|
||||
Quaternion.Euler(0, 0, baseAngle + coneRotationOffset);
|
||||
cone.transform.localScale = new Vector3(coneWidth, 0f, 1f);
|
||||
cone.transform.localRotation = Quaternion.Euler(0, 0, baseAngle - sweepAngle);
|
||||
cone.transform.localScale = new Vector3(0f, coneWidth, 1f);
|
||||
|
||||
coneRenderer = cone.AddComponent<SpriteRenderer>();
|
||||
coneRenderer.sprite = coneSprite;
|
||||
coneRenderer.sortingLayerName = "Effects";
|
||||
coneRenderer.color = coneColor;
|
||||
SpriteRenderer renderer = cone.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = coneSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = coneColor;
|
||||
}
|
||||
|
||||
// Create traveling glow
|
||||
GameObject glow = null;
|
||||
SpriteRenderer glowRenderer = null;
|
||||
if (glowSprite != null) {
|
||||
glow = new GameObject("ScoutGlow");
|
||||
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
glow.transform.localPosition = new Vector3(source.x, source.y, 7f);
|
||||
glow.transform.localScale = Vector3.one * glowStartScale;
|
||||
// Create scan lines
|
||||
GameObject[] scanLines = new GameObject[scanLineCount];
|
||||
|
||||
glowRenderer = glow.AddComponent<SpriteRenderer>();
|
||||
glowRenderer.sprite = glowSprite;
|
||||
glowRenderer.sortingLayerName = "Effects";
|
||||
glowRenderer.color = glowColor;
|
||||
for (int i = 0; i < scanLineCount; i++) {
|
||||
if (scanSprite == null) break;
|
||||
|
||||
GameObject scanLine = new GameObject($"ScanLine_{i}");
|
||||
scanLine.transform.SetParent(__hexGrid.gridCanvas.transform, false);
|
||||
scanLine.transform.localPosition = new Vector3(source.x, source.y, 4f);
|
||||
scanLine.transform.localScale = Vector3.one * 5f;
|
||||
|
||||
SpriteRenderer renderer = scanLine.AddComponent<SpriteRenderer>();
|
||||
renderer.sprite = scanSprite;
|
||||
renderer.sortingLayerName = "Effects";
|
||||
renderer.color = scanColor;
|
||||
|
||||
scanLines[i] = scanLine;
|
||||
}
|
||||
|
||||
// Eye appearance phase
|
||||
@@ -142,7 +137,7 @@ namespace Shardok {
|
||||
float t = elapsed / appearDuration;
|
||||
|
||||
if (eye != null) {
|
||||
float scale = eyeScale * t * (2f - t);
|
||||
float scale = eyeScale * t * (2f - t); // Overshoot slightly
|
||||
eye.transform.localScale = Vector3.one * scale;
|
||||
}
|
||||
|
||||
@@ -152,90 +147,89 @@ namespace Shardok {
|
||||
|
||||
if (eye != null) { eye.transform.localScale = Vector3.one * eyeScale; }
|
||||
|
||||
// Cone extension phase - cone extends, glow travels and grows
|
||||
// Scan sweep phase
|
||||
elapsed = 0f;
|
||||
|
||||
while (elapsed < extendDuration) {
|
||||
float t = elapsed / extendDuration;
|
||||
float easeT = t * (2f - t); // Ease out
|
||||
while (elapsed < scanDuration) {
|
||||
float t = elapsed / scanDuration;
|
||||
float sweepT = Mathf.Sin(t * Mathf.PI); // Smooth ease in-out
|
||||
|
||||
// Extend cone - Y axis is length after rotation, X is width
|
||||
// Rotate cone through sweep
|
||||
if (cone != null) {
|
||||
float length = distance * easeT;
|
||||
Vector2 conePos = source + direction * (length * 0.5f);
|
||||
cone.transform.localPosition = new Vector3(conePos.x, conePos.y, 6f);
|
||||
cone.transform.localScale =
|
||||
new Vector3(coneWidth * (1f - easeT * 0.3f), length, 1f);
|
||||
float currentAngle = baseAngle - sweepAngle + sweepAngle * 2f * t;
|
||||
cone.transform.localRotation = Quaternion.Euler(0, 0, currentAngle);
|
||||
|
||||
Color c = coneColor;
|
||||
c.a = coneColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 15f));
|
||||
coneRenderer.color = c;
|
||||
// Extend cone
|
||||
float length = coneLength * Mathf.Min(t * 3f, 1f);
|
||||
cone.transform.localScale = new Vector3(length, coneWidth, 1f);
|
||||
|
||||
// Pulse alpha
|
||||
var coneRenderer = cone.GetComponent<SpriteRenderer>();
|
||||
if (coneRenderer != null) {
|
||||
Color c = coneColor;
|
||||
c.a = coneColor.a * (0.7f + sweepT * 0.3f);
|
||||
coneRenderer.color = c;
|
||||
}
|
||||
}
|
||||
|
||||
// Move and grow glow at cone tip
|
||||
if (glow != null) {
|
||||
Vector2 glowPos = source + direction * distance * easeT;
|
||||
glow.transform.localPosition = new Vector3(glowPos.x, glowPos.y, 7f);
|
||||
// Animate scan lines along the cone
|
||||
for (int i = 0; i < scanLines.Length; i++) {
|
||||
if (scanLines[i] == null) continue;
|
||||
|
||||
float glowScale = Mathf.Lerp(glowStartScale, glowEndScale, easeT);
|
||||
glow.transform.localScale = Vector3.one * glowScale;
|
||||
float lineT = (t + (float)i / scanLineCount * 0.3f) % 1f;
|
||||
float lineAngle =
|
||||
(baseAngle - sweepAngle + sweepAngle * 2f * t) * Mathf.Deg2Rad;
|
||||
Vector2 lineDir = new Vector2(Mathf.Cos(lineAngle), Mathf.Sin(lineAngle));
|
||||
|
||||
Color c = glowColor;
|
||||
c.a = glowColor.a * (0.7f + 0.3f * Mathf.Sin(elapsed * 12f));
|
||||
glowRenderer.color = c;
|
||||
Vector2 pos = source + lineDir * coneLength * lineT;
|
||||
scanLines[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
|
||||
|
||||
var renderer = scanLines[i].GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = scanColor;
|
||||
c.a = scanColor.a * (1f - lineT) * sweepT;
|
||||
renderer.color = c;
|
||||
}
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Ensure final positions
|
||||
if (cone != null) {
|
||||
Vector2 conePos = source + direction * (distance * 0.5f);
|
||||
cone.transform.localPosition = new Vector3(conePos.x, conePos.y, 6f);
|
||||
cone.transform.localScale = new Vector3(coneWidth * 0.7f, distance, 1f);
|
||||
}
|
||||
if (glow != null) {
|
||||
glow.transform.localPosition = new Vector3(target.x, target.y, 7f);
|
||||
glow.transform.localScale = Vector3.one * glowEndScale;
|
||||
}
|
||||
|
||||
// Hold phase - glow pulses at target
|
||||
// Fade out
|
||||
float fadeDuration = 0.2f;
|
||||
elapsed = 0f;
|
||||
while (elapsed < holdDuration) {
|
||||
float t = elapsed / holdDuration;
|
||||
|
||||
if (glowRenderer != null) {
|
||||
Color c = glowColor;
|
||||
c.a = glowColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 10f));
|
||||
glowRenderer.color = c;
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Fade out phase
|
||||
elapsed = 0f;
|
||||
while (elapsed < fadeDuration) {
|
||||
float t = elapsed / fadeDuration;
|
||||
|
||||
if (eyeRenderer != null) {
|
||||
Color c = eyeColor;
|
||||
c.a = eyeColor.a * (1f - t);
|
||||
eyeRenderer.color = c;
|
||||
if (eye != null) {
|
||||
var renderer = eye.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = eyeColor;
|
||||
c.a = eyeColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
}
|
||||
eye.transform.localScale = Vector3.one * eyeScale * (1f - t * 0.3f);
|
||||
}
|
||||
|
||||
if (coneRenderer != null) {
|
||||
Color c = coneColor;
|
||||
c.a = coneColor.a * (1f - t);
|
||||
coneRenderer.color = c;
|
||||
if (cone != null) {
|
||||
var renderer = cone.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = coneColor;
|
||||
c.a = coneColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
}
|
||||
}
|
||||
|
||||
if (glowRenderer != null) {
|
||||
Color c = glowColor;
|
||||
c.a = glowColor.a * (1f - t);
|
||||
glowRenderer.color = c;
|
||||
foreach (var scanLine in scanLines) {
|
||||
if (scanLine == null) continue;
|
||||
var renderer = scanLine.GetComponent<SpriteRenderer>();
|
||||
if (renderer != null) {
|
||||
Color c = scanColor;
|
||||
c.a = scanColor.a * (1f - t);
|
||||
renderer.color = c;
|
||||
}
|
||||
}
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
@@ -245,7 +239,9 @@ namespace Shardok {
|
||||
// Cleanup
|
||||
if (eye != null) { Destroy(eye); }
|
||||
if (cone != null) { Destroy(cone); }
|
||||
if (glow != null) { Destroy(glow); }
|
||||
foreach (var scanLine in scanLines) {
|
||||
if (scanLine != null) { Destroy(scanLine); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-73
@@ -5,7 +5,6 @@ using System.Globalization;
|
||||
using System.Linq;
|
||||
using common;
|
||||
using eagle0;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Net.Eagle0.Shardok.Common;
|
||||
using TMPro;
|
||||
@@ -36,10 +35,7 @@ namespace Shardok {
|
||||
Fear,
|
||||
// Magic
|
||||
LightningBolt,
|
||||
MeteorStart,
|
||||
MeteorTarget,
|
||||
MeteorCast,
|
||||
MeteorCancel,
|
||||
HolyWave,
|
||||
RaiseDead,
|
||||
Control,
|
||||
@@ -245,8 +241,6 @@ namespace Shardok {
|
||||
turnHistoryPanel.Clear();
|
||||
this.gameObject.SetActive(false);
|
||||
} else {
|
||||
// Notify tutorial system of turn end
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnTurnEnded();
|
||||
Model.EndTurn();
|
||||
}
|
||||
}
|
||||
@@ -397,6 +391,8 @@ namespace Shardok {
|
||||
|
||||
Model = shardokGameModel;
|
||||
|
||||
Model.UpdateAction = ModelUpdated;
|
||||
|
||||
HexMap map = Model.Map;
|
||||
Texture[] textures = new Texture[map.RowCount * map.ColumnCount];
|
||||
Coords coords = new Coords();
|
||||
@@ -422,17 +418,10 @@ namespace Shardok {
|
||||
hexGrid.SetUp();
|
||||
hexGrid.gameObject.SetActive(true);
|
||||
|
||||
// Set UpdateAction after hexGrid is ready to avoid race condition
|
||||
Model.UpdateAction = ModelUpdated;
|
||||
|
||||
SetHeroLabels();
|
||||
|
||||
MainQueue.Q.EnqueueForNextUpdate(() => { ModelUpdated(); });
|
||||
|
||||
// Initialize tutorial system for tactical combat
|
||||
TutorialManager.Instance?.Initialize(null, this);
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnBattleEntered(Model);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
|
||||
#endif
|
||||
@@ -552,9 +541,6 @@ namespace Shardok {
|
||||
var historyEntry = Model.History[i];
|
||||
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
|
||||
|
||||
// Notify tutorial system of battle action
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnBattleAction(historyEntry);
|
||||
|
||||
ActionType type = historyEntry.Type;
|
||||
AnimationType animationType = AnimationTypeForAction(type);
|
||||
|
||||
@@ -579,12 +565,7 @@ namespace Shardok {
|
||||
if (Model.UnitsById.TryGetValue(
|
||||
historyEntry.Actor.Value,
|
||||
out var actorUnit)) {
|
||||
// For Move actions, use stored source coords (unit's pre-move location)
|
||||
// For other actions, use current unit location
|
||||
int sourceIndex =
|
||||
Model.MoveSourceCoords.TryGetValue(i, out var sourceCoords)
|
||||
? MapCoordsToGridIndex(sourceCoords)
|
||||
: MapCoordsToGridIndex(actorUnit.Location);
|
||||
int sourceIndex = MapCoordsToGridIndex(actorUnit.Location);
|
||||
|
||||
// Get target - either coords (archery) or unit location (melee)
|
||||
int targetIndex = -1;
|
||||
@@ -1197,9 +1178,6 @@ namespace Shardok {
|
||||
}
|
||||
_selectedGridIndex = selectedIndex;
|
||||
RedrawCommandOverlays(selectedIndex, selectedIndex);
|
||||
|
||||
// Notify tutorial system of unit selection
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnUnitSelected(selectedIndex);
|
||||
}
|
||||
|
||||
public void HandleActionClick(int clickedIndex) {
|
||||
@@ -1365,8 +1343,8 @@ namespace Shardok {
|
||||
case CommandType.MoveCommand: return AnimationType.Move;
|
||||
// Magic
|
||||
case CommandType.LightningBoltCommand: return AnimationType.LightningBolt;
|
||||
case CommandType.MeteorStartCommand: return AnimationType.MeteorStart;
|
||||
case CommandType.MeteorTargetCommand: return AnimationType.MeteorTarget;
|
||||
case CommandType.MeteorStartCommand:
|
||||
case CommandType.MeteorTargetCommand: return AnimationType.MeteorCast;
|
||||
case CommandType.HolyWaveCommand: return AnimationType.HolyWave;
|
||||
case CommandType.RaiseDeadCommand: return AnimationType.RaiseDead;
|
||||
case CommandType.ControlCommand: return AnimationType.Control;
|
||||
@@ -1405,10 +1383,9 @@ namespace Shardok {
|
||||
case ActionType.FearAttack: return AnimationType.Fear;
|
||||
// Magic
|
||||
case ActionType.LightningBolt: return AnimationType.LightningBolt;
|
||||
case ActionType.MeteorStart: return AnimationType.MeteorStart;
|
||||
case ActionType.MeteorTarget: return AnimationType.MeteorTarget;
|
||||
case ActionType.MeteorStart:
|
||||
case ActionType.MeteorTarget:
|
||||
case ActionType.MeteorCast: return AnimationType.MeteorCast;
|
||||
case ActionType.MeteorCancel: return AnimationType.MeteorCancel;
|
||||
case ActionType.HolyWave:
|
||||
case ActionType.HolyWaveDamage: return AnimationType.HolyWave;
|
||||
case ActionType.RaisedUndead: return AnimationType.RaiseDead;
|
||||
@@ -1448,10 +1425,7 @@ namespace Shardok {
|
||||
case AnimationType.Fear: return ActionType.FearAttack;
|
||||
// Magic
|
||||
case AnimationType.LightningBolt: return ActionType.LightningBolt;
|
||||
case AnimationType.MeteorStart: return ActionType.MeteorStart;
|
||||
case AnimationType.MeteorTarget: return ActionType.MeteorTarget;
|
||||
case AnimationType.MeteorCast: return ActionType.MeteorCast;
|
||||
case AnimationType.MeteorCancel: return ActionType.MeteorCancel;
|
||||
case AnimationType.HolyWave: return ActionType.HolyWave;
|
||||
case AnimationType.RaiseDead: return ActionType.RaisedUndead;
|
||||
case AnimationType.Control: return ActionType.Controlled;
|
||||
@@ -1575,11 +1549,8 @@ namespace Shardok {
|
||||
}
|
||||
break;
|
||||
case AnimationType.Move:
|
||||
if (moveAnimator != null && attackerUnit != null) {
|
||||
moveAnimator.AnimateMove(
|
||||
sourceGridIndex,
|
||||
targetGridIndex,
|
||||
attackerUnit.Battalion.Type);
|
||||
if (moveAnimator != null) {
|
||||
moveAnimator.AnimateMove(sourceGridIndex, targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.LightningBolt:
|
||||
@@ -1597,26 +1568,11 @@ namespace Shardok {
|
||||
raiseDeadAnimator.AnimateRaiseDead(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorStart:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorStart(sourceGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorTarget:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorTarget(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorCast:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorCast(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorCancel:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorCancel(sourceGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.HolyWave:
|
||||
if (holyWaveAnimator != null) {
|
||||
var undeadCells = FindNearbyUndeadCells(sourceGridIndex, 2);
|
||||
@@ -1738,11 +1694,8 @@ namespace Shardok {
|
||||
}
|
||||
break;
|
||||
case AnimationType.Move:
|
||||
if (moveAnimator != null && attackerUnit != null) {
|
||||
moveAnimator.AnimateMove(
|
||||
sourceGridIndex,
|
||||
targetGridIndex,
|
||||
attackerUnit.Battalion.Type);
|
||||
if (moveAnimator != null) {
|
||||
moveAnimator.AnimateMove(sourceGridIndex, targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.LightningBolt:
|
||||
@@ -1760,26 +1713,11 @@ namespace Shardok {
|
||||
raiseDeadAnimator.AnimateRaiseDead(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorStart:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorStart(sourceGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorTarget:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorTarget(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorCast:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorCast(targetGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.MeteorCancel:
|
||||
if (meteorAnimator != null) {
|
||||
meteorAnimator.AnimateMeteorCancel(sourceGridIndex);
|
||||
}
|
||||
break;
|
||||
case AnimationType.HolyWave:
|
||||
if (holyWaveAnimator != null) {
|
||||
var undeadCells = FindNearbyUndeadCells(sourceGridIndex, 2);
|
||||
|
||||
@@ -75,13 +75,6 @@ public class ShardokGameModel {
|
||||
ReserveUnitsById.Values.FirstOrDefault(u => u.Location.Equals(location));
|
||||
|
||||
public List<ActionResultView> History { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tracks the source coordinates for Move actions, keyed by history index.
|
||||
/// Used for animations since the model is fully updated before animations play.
|
||||
/// </summary>
|
||||
public Dictionary<int, Coords> MoveSourceCoords { get; } = new();
|
||||
|
||||
public PlayerId PlayerId { get; private set; }
|
||||
public GameStatus GameStatus { get; private set; }
|
||||
public RoundId CurrentRound { get; private set; }
|
||||
@@ -409,14 +402,6 @@ public class ShardokGameModel {
|
||||
}
|
||||
|
||||
private void HandleNewHistoryEntry(ActionResultView entry) {
|
||||
// For Move actions, record the source coordinates BEFORE applying the diff
|
||||
// This is needed for animations since all diffs are applied before animations play
|
||||
if (entry.Type == ActionType.Move && entry.Actor != null) {
|
||||
if (UnitsById.TryGetValue(entry.Actor.Value, out var actorUnit)) {
|
||||
MoveSourceCoords[History.Count] = actorUnit.Location;
|
||||
}
|
||||
}
|
||||
|
||||
History.Add(entry);
|
||||
var gsvDiff = entry.GameStateViewDiff;
|
||||
if (gsvDiff == null) {
|
||||
|
||||
@@ -43,18 +43,6 @@ namespace Shardok {
|
||||
[Tooltip("Rotation arc of the swing (degrees)")]
|
||||
public float swingArc = 45f;
|
||||
|
||||
[Tooltip("Base rotation offset for hammer (degrees)")]
|
||||
public float hammerBaseRotation = 180f;
|
||||
|
||||
[Tooltip("Flip hammer horizontally")]
|
||||
public bool hammerFlipHorizontal = true;
|
||||
|
||||
[Tooltip("Base rotation offset for alternate tool (degrees)")]
|
||||
public float alternateBaseRotation = 0f;
|
||||
|
||||
[Tooltip("Flip alternate tool horizontally")]
|
||||
public bool alternateFlipHorizontal = false;
|
||||
|
||||
private HexGrid __hexGrid;
|
||||
|
||||
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
|
||||
@@ -91,11 +79,9 @@ namespace Shardok {
|
||||
}
|
||||
|
||||
private IEnumerator AnimateStrikes(Vector2 target, bool useBridgeStyle) {
|
||||
bool useAlternate = useBridgeStyle && alternateToolSprite != null;
|
||||
Sprite toolSprite =
|
||||
useAlternate ? alternateToolSprite : hammerSprite ?? alternateToolSprite;
|
||||
float baseRotation = useAlternate ? alternateBaseRotation : hammerBaseRotation;
|
||||
bool flipHorizontal = useAlternate ? alternateFlipHorizontal : hammerFlipHorizontal;
|
||||
Sprite toolSprite = useBridgeStyle && alternateToolSprite != null
|
||||
? alternateToolSprite
|
||||
: hammerSprite ?? alternateToolSprite;
|
||||
|
||||
// Create tool
|
||||
GameObject tool = new GameObject("Tool");
|
||||
@@ -104,8 +90,7 @@ namespace Shardok {
|
||||
SpriteRenderer toolRenderer = tool.AddComponent<SpriteRenderer>();
|
||||
toolRenderer.sprite = toolSprite;
|
||||
toolRenderer.sortingLayerName = "Effects";
|
||||
float xScale = flipHorizontal ? -toolScale : toolScale;
|
||||
tool.transform.localScale = new Vector3(xScale, toolScale, toolScale);
|
||||
tool.transform.localScale = Vector3.one * toolScale;
|
||||
|
||||
// Starting position (raised above target)
|
||||
Vector3 strikePoint = new Vector3(target.x, target.y, 5f);
|
||||
@@ -115,7 +100,7 @@ namespace Shardok {
|
||||
for (int i = 0; i < strikeCount; i++) {
|
||||
// Raise tool
|
||||
tool.transform.localPosition = raisePoint;
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation - swingArc);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, -swingArc);
|
||||
|
||||
float elapsed = 0f;
|
||||
float downDuration = strikeDuration * 0.4f;
|
||||
@@ -127,14 +112,14 @@ namespace Shardok {
|
||||
|
||||
tool.transform.localPosition = Vector3.Lerp(raisePoint, strikePoint, easeT);
|
||||
float angle = Mathf.Lerp(-swingArc, 0f, easeT);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation + angle);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, angle);
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
}
|
||||
|
||||
tool.transform.localPosition = strikePoint;
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, 0);
|
||||
|
||||
// Spawn sparks on impact
|
||||
yield return StartCoroutine(SpawnSparks(target));
|
||||
@@ -152,7 +137,7 @@ namespace Shardok {
|
||||
|
||||
tool.transform.localPosition = Vector3.Lerp(strikePoint, raisePoint, easeT);
|
||||
float angle = Mathf.Lerp(0f, -swingArc, easeT);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation + angle);
|
||||
tool.transform.localRotation = Quaternion.Euler(0, 0, angle);
|
||||
|
||||
elapsed += Time.deltaTime;
|
||||
yield return null;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0deea4d267414bcd9421d74e9c1d0d7e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f80e1b28dcb84478b8a97821d82dbde7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Ordered collection of tutorial steps forming a complete tutorial.
|
||||
/// Can be used for onboarding or contextual tutorials.
|
||||
/// Create instances via Assets > Create > Eagle0 > Tutorial Sequence.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(fileName = "TutorialSequence", menuName = "Eagle0/Tutorial Sequence")]
|
||||
public class TutorialSequence : ScriptableObject {
|
||||
[Header("Identity")]
|
||||
[Tooltip("Unique identifier for this tutorial sequence")]
|
||||
public string SequenceId;
|
||||
|
||||
[Tooltip("Display name shown to user")]
|
||||
public string DisplayName;
|
||||
|
||||
[Tooltip("Whether this is the main onboarding sequence")]
|
||||
public bool IsOnboarding;
|
||||
|
||||
[Header("Steps")]
|
||||
[Tooltip("Ordered list of tutorial steps")]
|
||||
public List<TutorialStep> Steps = new List<TutorialStep>();
|
||||
|
||||
[Header("Prerequisites")]
|
||||
[Tooltip("Tutorial IDs that must be completed before this one can trigger")]
|
||||
public List<string> RequiredCompletedTutorials = new List<string>();
|
||||
|
||||
[Header("Events")]
|
||||
[Tooltip("Called when this sequence starts")]
|
||||
public UnityEvent OnSequenceStart;
|
||||
|
||||
[Tooltip("Called when this sequence completes normally")]
|
||||
public UnityEvent OnSequenceComplete;
|
||||
|
||||
[Tooltip("Called when user skips this sequence")]
|
||||
public UnityEvent OnSequenceSkipped;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a step by index, or null if out of range.
|
||||
/// </summary>
|
||||
public TutorialStep GetStep(int index) {
|
||||
if (index < 0 || index >= Steps.Count) return null;
|
||||
return Steps[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the index of a step by its ID, or -1 if not found.
|
||||
/// </summary>
|
||||
public int IndexOfStep(string stepId) {
|
||||
if (string.IsNullOrEmpty(stepId)) return -1;
|
||||
|
||||
for (int i = 0; i < Steps.Count; i++) {
|
||||
if (Steps[i].StepId == stepId) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Total number of steps in this sequence.
|
||||
/// </summary>
|
||||
public int StepCount => Steps?.Count ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if all prerequisites are met.
|
||||
/// </summary>
|
||||
public bool ArePrerequisitesMet(TutorialState state) {
|
||||
if (RequiredCompletedTutorials == null || RequiredCompletedTutorials.Count == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var reqId in RequiredCompletedTutorials) {
|
||||
if (!state.HasCompletedTutorial(reqId)) { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>
|
||||
/// Editor validation to catch common issues.
|
||||
/// </summary>
|
||||
private void OnValidate() {
|
||||
// Ensure SequenceId is set
|
||||
if (string.IsNullOrEmpty(SequenceId)) { SequenceId = name; }
|
||||
|
||||
// Check for duplicate step IDs
|
||||
var seenIds = new HashSet<string>();
|
||||
foreach (var step in Steps) {
|
||||
if (!string.IsNullOrEmpty(step.StepId)) {
|
||||
if (!seenIds.Add(step.StepId)) {
|
||||
Debug.LogWarning(
|
||||
$"TutorialSequence '{SequenceId}': Duplicate step ID '{step.StepId}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97da9ce2a5d7418998c026d559ad0a13
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// How the tutorial step should be displayed to the user.
|
||||
/// </summary>
|
||||
public enum TutorialDisplayMode {
|
||||
/// <summary>Full modal window blocking game interaction (for important concepts).</summary>
|
||||
Modal,
|
||||
|
||||
/// <summary>Semi-transparent overlay highlighting a specific UI element.</summary>
|
||||
Overlay,
|
||||
|
||||
/// <summary>Small tooltip positioned near target element.</summary>
|
||||
Tooltip,
|
||||
|
||||
/// <summary>Subtle pulsing indicator only (no text popup).</summary>
|
||||
Hint,
|
||||
|
||||
/// <summary>Invisible step for waiting on game events.</summary>
|
||||
None
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the tutorial step is completed/advanced.
|
||||
/// </summary>
|
||||
public enum TutorialCompletionType {
|
||||
/// <summary>User clicks Continue/Got it button.</summary>
|
||||
ButtonClick,
|
||||
|
||||
/// <summary>Specific game event triggers completion.</summary>
|
||||
GameEvent,
|
||||
|
||||
/// <summary>User interacts with the highlighted UI element.</summary>
|
||||
UIInteraction,
|
||||
|
||||
/// <summary>Auto-advances after a delay.</summary>
|
||||
Timer,
|
||||
|
||||
/// <summary>Custom condition checked each frame.</summary>
|
||||
Condition
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single step within a tutorial sequence.
|
||||
/// Supports multiple display modes and completion conditions.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class TutorialStep {
|
||||
[Header("Identity")]
|
||||
[Tooltip("Unique identifier for this step")]
|
||||
public string StepId;
|
||||
|
||||
[Header("Display Content")]
|
||||
[Tooltip("Title shown in modal or tooltip")]
|
||||
public string Title;
|
||||
|
||||
[Tooltip("Main description text")]
|
||||
[TextArea(3, 10)]
|
||||
public string Description;
|
||||
|
||||
[Tooltip("Optional icon/image to display")]
|
||||
public Sprite Icon;
|
||||
|
||||
[Tooltip("How this step should be displayed")]
|
||||
public TutorialDisplayMode DisplayMode = TutorialDisplayMode.Modal;
|
||||
|
||||
[Header("UI Targeting")]
|
||||
[Tooltip("Path to GameObject to highlight (e.g., 'Canvas/CommandPanel/MarchButton')")]
|
||||
public string TargetGameObjectPath;
|
||||
|
||||
[Tooltip("Offset from target element for tooltip/arrow positioning")]
|
||||
public Vector2 HighlightOffset;
|
||||
|
||||
[Tooltip("Whether the highlight should pulse")]
|
||||
public bool HighlightPulsing = true;
|
||||
|
||||
[Header("Completion")]
|
||||
[Tooltip("How this step is completed")]
|
||||
public TutorialCompletionType CompletionType = TutorialCompletionType.ButtonClick;
|
||||
|
||||
[Tooltip("Event ID to wait for (when CompletionType is GameEvent)")]
|
||||
public string CompletionEventId;
|
||||
|
||||
[Tooltip("Delay in seconds before auto-advance (when CompletionType is Timer)")]
|
||||
public float AutoAdvanceDelay;
|
||||
|
||||
[Header("Flow Control")]
|
||||
[Tooltip("Whether user can skip this step")]
|
||||
public bool AllowSkip = true;
|
||||
|
||||
[Tooltip("Jump to specific step ID instead of sequential (empty = next step)")]
|
||||
public string NextStepOverride;
|
||||
|
||||
[Header("Audio")]
|
||||
[Tooltip("Optional narration audio")]
|
||||
public AudioClip NarrationClip;
|
||||
|
||||
[Tooltip("Optional sound effect when step appears")]
|
||||
public AudioClip EffectClip;
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad37a68e71e24b6a88ace636f74020dd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e2fbfb348eb443cb91395b3712870da
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using eagle;
|
||||
using Shardok;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Registry of tutorial triggers that respond to game events.
|
||||
/// Integrates with game controllers to detect first-encounter situations.
|
||||
/// </summary>
|
||||
public class TutorialTriggerRegistry {
|
||||
private readonly TutorialManager _manager;
|
||||
private EagleGameController _eagleController;
|
||||
private ShardokGameController _shardokController;
|
||||
|
||||
// Registered contextual tutorials by ID
|
||||
private Dictionary<string, TutorialSequence> _tutorialSequences =
|
||||
new Dictionary<string, TutorialSequence>();
|
||||
|
||||
// Event-to-tutorial mapping
|
||||
private Dictionary<string, List<string>> _eventTriggers =
|
||||
new Dictionary<string, List<string>>();
|
||||
|
||||
public TutorialTriggerRegistry(TutorialManager manager) { _manager = manager; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialize with game controller references.
|
||||
/// </summary>
|
||||
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
|
||||
if (eagle != null) { _eagleController = eagle; }
|
||||
if (shardok != null) { _shardokController = shardok; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a tutorial sequence that can be triggered by events.
|
||||
/// </summary>
|
||||
public void RegisterTutorial(TutorialSequence sequence, params string[] triggerEvents) {
|
||||
if (sequence == null || string.IsNullOrEmpty(sequence.SequenceId)) return;
|
||||
|
||||
_tutorialSequences[sequence.SequenceId] = sequence;
|
||||
|
||||
foreach (var eventId in triggerEvents) {
|
||||
if (!_eventTriggers.ContainsKey(eventId)) {
|
||||
_eventTriggers[eventId] = new List<string>();
|
||||
}
|
||||
_eventTriggers[eventId].Add(sequence.SequenceId);
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"TutorialTriggerRegistry: Registered '{sequence.SequenceId}' for events: {string.Join(", ", triggerEvents)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a tutorial sequence by ID.
|
||||
/// </summary>
|
||||
public TutorialSequence GetSequenceById(string tutorialId) {
|
||||
_tutorialSequences.TryGetValue(tutorialId, out var sequence);
|
||||
return sequence;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a game event occurs.
|
||||
/// Checks for tutorials that should be triggered.
|
||||
/// </summary>
|
||||
public void OnGameEvent(string eventId, object context = null) {
|
||||
if (!_eventTriggers.TryGetValue(eventId, out var tutorialIds)) return;
|
||||
|
||||
foreach (var tutorialId in tutorialIds) {
|
||||
if (_manager.State.HasCompletedTutorial(tutorialId)) continue;
|
||||
|
||||
var sequence = GetSequenceById(tutorialId);
|
||||
if (sequence != null && sequence.ArePrerequisitesMet(_manager.State)) {
|
||||
_manager.TriggerContextualTutorial(tutorialId);
|
||||
break; // Only trigger one tutorial per event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Strategic Layer Events ==========
|
||||
|
||||
/// <summary>
|
||||
/// Called when the game model is updated.
|
||||
/// </summary>
|
||||
public void OnModelUpdated(IGameModel model, IGameModel previousModel) {
|
||||
if (model == null) return;
|
||||
|
||||
// Check for first battle available
|
||||
if (model.RunningShardokGameModels?.Count > 0) {
|
||||
if (previousModel?.RunningShardokGameModels == null ||
|
||||
previousModel.RunningShardokGameModels.Count == 0) {
|
||||
OnGameEvent("first_battle_available");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for diplomacy offers
|
||||
// Check for riots
|
||||
// Check for hero recruitment opportunities
|
||||
// etc. - These would check model state changes
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a province is selected.
|
||||
/// </summary>
|
||||
public void OnProvinceSelected(object province) {
|
||||
// Track first province selection for onboarding
|
||||
OnGameEvent("province_selected", province);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a command is issued.
|
||||
/// </summary>
|
||||
public void OnCommandIssued(object command) {
|
||||
// Track first command for onboarding
|
||||
OnGameEvent("command_issued", command);
|
||||
|
||||
// Could also check command type for specific tutorials
|
||||
// e.g., first diplomacy command, first weather control, etc.
|
||||
}
|
||||
|
||||
// ========== Tactical Layer Events ==========
|
||||
|
||||
/// <summary>
|
||||
/// Called when entering a battle.
|
||||
/// </summary>
|
||||
public void OnBattleEntered(object battleModel) {
|
||||
OnGameEvent("battle_entered", battleModel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a battle action result is received.
|
||||
/// </summary>
|
||||
public void OnBattleAction(object actionResult) {
|
||||
OnGameEvent("battle_action", actionResult);
|
||||
|
||||
// Check for specific action types that need tutorials
|
||||
// e.g., first spell cast, first charge, etc.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a unit is selected in tactical view.
|
||||
/// </summary>
|
||||
public void OnUnitSelected(int cellIndex) { OnGameEvent("unit_selected", cellIndex); }
|
||||
|
||||
/// <summary>
|
||||
/// Called when turn ends in tactical combat.
|
||||
/// </summary>
|
||||
public void OnTurnEnded() { OnGameEvent("turn_ended"); }
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23dab249a548427091dc94baadedcebd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,374 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using eagle;
|
||||
using Shardok;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Central singleton managing tutorial state, triggers, and UI display.
|
||||
/// Hooks into EagleGameController and ShardokGameController for game events.
|
||||
/// </summary>
|
||||
public class TutorialManager : MonoBehaviour {
|
||||
public static TutorialManager Instance { get; private set; }
|
||||
|
||||
[Header("Configuration")]
|
||||
[Tooltip("Whether tutorials are enabled")]
|
||||
public bool TutorialsEnabled = true;
|
||||
|
||||
[Tooltip("The main onboarding tutorial sequence")]
|
||||
public TutorialSequence OnboardingSequence;
|
||||
|
||||
[Header("UI References")]
|
||||
[Tooltip("Reference to the tutorial UI manager")]
|
||||
public TutorialUIManager UIManager;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable verbose logging")]
|
||||
public bool DebugLogging;
|
||||
|
||||
// State
|
||||
private TutorialState _state;
|
||||
public TutorialState State => _state;
|
||||
|
||||
// Trigger registry
|
||||
private TutorialTriggerRegistry _triggerRegistry;
|
||||
public TutorialTriggerRegistry TriggerRegistry => _triggerRegistry;
|
||||
|
||||
// Active sequence tracking
|
||||
private TutorialSequence _activeSequence;
|
||||
private int _currentStepIndex;
|
||||
private Coroutine _activeStepCoroutine;
|
||||
|
||||
/// <summary>
|
||||
/// Whether we're currently in an onboarding sequence.
|
||||
/// </summary>
|
||||
public bool IsOnboarding => _activeSequence != null && _activeSequence.IsOnboarding;
|
||||
|
||||
/// <summary>
|
||||
/// Whether any tutorial sequence is currently active.
|
||||
/// </summary>
|
||||
public bool IsSequenceActive => _activeSequence != null;
|
||||
|
||||
/// <summary>
|
||||
/// Current step being displayed, or null if none.
|
||||
/// </summary>
|
||||
public TutorialStep CurrentStep => _activeSequence?.GetStep(_currentStepIndex);
|
||||
|
||||
private void Awake() {
|
||||
// Singleton setup
|
||||
if (Instance != null && Instance != this) {
|
||||
Debug.LogWarning("TutorialManager: Duplicate instance destroyed");
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
|
||||
// Initialize state
|
||||
_state = TutorialState.Load();
|
||||
_triggerRegistry = new TutorialTriggerRegistry(this);
|
||||
|
||||
Log("TutorialManager initialized");
|
||||
}
|
||||
|
||||
private void OnDestroy() {
|
||||
if (Instance == this) { Instance = null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize with game controller references.
|
||||
/// Call from EagleGameController.SetUpGame() and ShardokGameController.SetUpGame().
|
||||
/// </summary>
|
||||
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
|
||||
_triggerRegistry?.Initialize(eagle, shardok);
|
||||
Log($"TutorialManager initialized with controllers - Eagle: {eagle != null}, Shardok: {shardok != null}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the main onboarding tutorial sequence.
|
||||
/// </summary>
|
||||
public void StartOnboarding() {
|
||||
if (!TutorialsEnabled || _state.AllTutorialsDisabled) {
|
||||
Log("StartOnboarding: Tutorials disabled, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_state.OnboardingCompleted) {
|
||||
Log("StartOnboarding: Already completed, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (OnboardingSequence == null) {
|
||||
Debug.LogWarning("TutorialManager: No onboarding sequence assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
StartSequence(OnboardingSequence, _state.OnboardingStepReached);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a specific tutorial sequence.
|
||||
/// </summary>
|
||||
public void StartSequence(TutorialSequence sequence, int startStep = 0) {
|
||||
if (sequence == null) return;
|
||||
|
||||
if (!TutorialsEnabled || _state.AllTutorialsDisabled) {
|
||||
Log($"StartSequence: Tutorials disabled, skipping '{sequence.SequenceId}'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check prerequisites
|
||||
if (!sequence.ArePrerequisitesMet(_state)) {
|
||||
Log($"StartSequence: Prerequisites not met for '{sequence.SequenceId}'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any active sequence
|
||||
if (_activeSequence != null) { StopCurrentSequence(skipped: true); }
|
||||
|
||||
_activeSequence = sequence;
|
||||
_currentStepIndex = Mathf.Clamp(startStep, 0, sequence.StepCount - 1);
|
||||
|
||||
Log($"Starting tutorial sequence '{sequence.SequenceId}' at step {_currentStepIndex}");
|
||||
sequence.OnSequenceStart?.Invoke();
|
||||
|
||||
ShowCurrentStep();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a contextual tutorial by ID if not already seen.
|
||||
/// </summary>
|
||||
public void TriggerContextualTutorial(string tutorialId) {
|
||||
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return;
|
||||
if (_state.HasCompletedTutorial(tutorialId)) return;
|
||||
|
||||
// Look up the tutorial sequence by ID
|
||||
var sequence = _triggerRegistry?.GetSequenceById(tutorialId);
|
||||
if (sequence != null) { StartSequence(sequence); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next step in the current sequence.
|
||||
/// </summary>
|
||||
public void AdvanceStep() {
|
||||
if (_activeSequence == null) return;
|
||||
|
||||
var currentStep = CurrentStep;
|
||||
string nextStepId = currentStep?.NextStepOverride;
|
||||
|
||||
// Determine next step index
|
||||
int nextIndex;
|
||||
if (!string.IsNullOrEmpty(nextStepId)) {
|
||||
nextIndex = _activeSequence.IndexOfStep(nextStepId);
|
||||
if (nextIndex < 0) nextIndex = _currentStepIndex + 1;
|
||||
} else {
|
||||
nextIndex = _currentStepIndex + 1;
|
||||
}
|
||||
|
||||
// Check if sequence is complete
|
||||
if (nextIndex >= _activeSequence.StepCount) {
|
||||
CompleteCurrentSequence();
|
||||
return;
|
||||
}
|
||||
|
||||
_currentStepIndex = nextIndex;
|
||||
|
||||
// Track onboarding progress
|
||||
if (_activeSequence.IsOnboarding) { _state.SetOnboardingStep(_currentStepIndex); }
|
||||
|
||||
Log($"Advanced to step {_currentStepIndex}: '{CurrentStep?.StepId}'");
|
||||
ShowCurrentStep();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips the current tutorial step.
|
||||
/// </summary>
|
||||
public void SkipCurrentStep() {
|
||||
if (_activeSequence == null) return;
|
||||
|
||||
var step = CurrentStep;
|
||||
if (step != null && !step.AllowSkip) {
|
||||
Log("SkipCurrentStep: Current step does not allow skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
AdvanceStep();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips all remaining steps in the current sequence.
|
||||
/// </summary>
|
||||
public void SkipCurrentSequence() {
|
||||
if (_activeSequence == null) return;
|
||||
StopCurrentSequence(skipped: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skips the entire onboarding and marks it complete.
|
||||
/// </summary>
|
||||
public void SkipAllOnboarding() {
|
||||
_state.CompleteOnboarding();
|
||||
if (_activeSequence != null && _activeSequence.IsOnboarding) {
|
||||
StopCurrentSequence(skipped: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables all tutorials permanently (until re-enabled).
|
||||
/// </summary>
|
||||
public void DisableAllTutorials() {
|
||||
_state.DisableAllTutorials();
|
||||
if (_activeSequence != null) { StopCurrentSequence(skipped: true); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a specific tutorial has been seen.
|
||||
/// </summary>
|
||||
public bool HasSeenTutorial(string tutorialId) {
|
||||
return _state.HasCompletedTutorial(tutorialId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a hint should be shown (not dismissed).
|
||||
/// </summary>
|
||||
public bool ShouldShowHint(string hintId) {
|
||||
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return false;
|
||||
return !_state.HasDismissedHint(hintId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a game event occurs that might trigger or complete a tutorial step.
|
||||
/// </summary>
|
||||
public void OnGameEvent(string eventId, object context = null) {
|
||||
Log($"Game event: '{eventId}'");
|
||||
|
||||
// Check if this completes the current step
|
||||
var currentStep = CurrentStep;
|
||||
if (currentStep != null &&
|
||||
currentStep.CompletionType == TutorialCompletionType.GameEvent &&
|
||||
currentStep.CompletionEventId == eventId) {
|
||||
AdvanceStep();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check triggers for contextual tutorials
|
||||
_triggerRegistry?.OnGameEvent(eventId, context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all tutorial progress (for testing).
|
||||
/// </summary>
|
||||
public void ResetAllProgress() {
|
||||
StopCurrentSequence(skipped: true);
|
||||
_state.Reset();
|
||||
Log("All tutorial progress reset");
|
||||
}
|
||||
|
||||
private void ShowCurrentStep() {
|
||||
var step = CurrentStep;
|
||||
if (step == null) {
|
||||
CompleteCurrentSequence();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any previous step coroutine
|
||||
if (_activeStepCoroutine != null) {
|
||||
StopCoroutine(_activeStepCoroutine);
|
||||
_activeStepCoroutine = null;
|
||||
}
|
||||
|
||||
// Hide previous UI
|
||||
UIManager?.HideAll();
|
||||
|
||||
// Handle different display modes
|
||||
switch (step.DisplayMode) {
|
||||
case TutorialDisplayMode.Modal:
|
||||
UIManager?.ShowModal(
|
||||
step,
|
||||
_currentStepIndex,
|
||||
_activeSequence.StepCount,
|
||||
OnStepComplete,
|
||||
OnStepSkip,
|
||||
_activeSequence.IsOnboarding);
|
||||
break;
|
||||
|
||||
case TutorialDisplayMode.Overlay:
|
||||
UIManager?.ShowOverlay(step, OnStepComplete);
|
||||
break;
|
||||
|
||||
case TutorialDisplayMode.Tooltip:
|
||||
UIManager?.ShowTooltip(step, OnStepComplete);
|
||||
break;
|
||||
|
||||
case TutorialDisplayMode.Hint:
|
||||
UIManager?.ShowHint(step.StepId, step.TargetGameObjectPath, step.Description);
|
||||
// Hints don't block - advance immediately
|
||||
AdvanceStep();
|
||||
break;
|
||||
|
||||
case TutorialDisplayMode.None:
|
||||
// Invisible step - just wait for completion event
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle timer-based completion
|
||||
if (step.CompletionType == TutorialCompletionType.Timer && step.AutoAdvanceDelay > 0) {
|
||||
_activeStepCoroutine = StartCoroutine(AutoAdvanceAfterDelay(step.AutoAdvanceDelay));
|
||||
}
|
||||
|
||||
// Play audio if present
|
||||
if (step.EffectClip != null) {
|
||||
// TODO: Play via audio manager
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator AutoAdvanceAfterDelay(float delay) {
|
||||
yield return new WaitForSeconds(delay);
|
||||
AdvanceStep();
|
||||
}
|
||||
|
||||
private void OnStepComplete() { AdvanceStep(); }
|
||||
|
||||
private void OnStepSkip() { SkipCurrentStep(); }
|
||||
|
||||
private void CompleteCurrentSequence() {
|
||||
if (_activeSequence == null) return;
|
||||
|
||||
var sequence = _activeSequence;
|
||||
Log($"Completed tutorial sequence '{sequence.SequenceId}'");
|
||||
|
||||
// Mark as completed
|
||||
_state.MarkTutorialCompleted(sequence.SequenceId);
|
||||
if (sequence.IsOnboarding) { _state.CompleteOnboarding(); }
|
||||
|
||||
sequence.OnSequenceComplete?.Invoke();
|
||||
|
||||
_activeSequence = null;
|
||||
_currentStepIndex = 0;
|
||||
UIManager?.HideAll();
|
||||
}
|
||||
|
||||
private void StopCurrentSequence(bool skipped) {
|
||||
if (_activeSequence == null) return;
|
||||
|
||||
var sequence = _activeSequence;
|
||||
Log($"Stopped tutorial sequence '{sequence.SequenceId}' (skipped: {skipped})");
|
||||
|
||||
if (skipped) { sequence.OnSequenceSkipped?.Invoke(); }
|
||||
|
||||
if (_activeStepCoroutine != null) {
|
||||
StopCoroutine(_activeStepCoroutine);
|
||||
_activeStepCoroutine = null;
|
||||
}
|
||||
|
||||
_activeSequence = null;
|
||||
_currentStepIndex = 0;
|
||||
UIManager?.HideAll();
|
||||
}
|
||||
|
||||
private void Log(string message) {
|
||||
if (DebugLogging) { Debug.Log($"[Tutorial] {message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c529affa73b04d129ec5826ed0ac75e7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,186 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Persistence layer for tutorial completion state.
|
||||
/// Uses PlayerPrefs with JSON serialization for complex state.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class TutorialState {
|
||||
private const string PrefsKey = "Eagle0_TutorialState";
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
// Persisted data
|
||||
public int Version = CurrentVersion;
|
||||
public bool OnboardingCompleted;
|
||||
public int OnboardingStepReached;
|
||||
public List<string> CompletedTutorials = new List<string>();
|
||||
public List<string> DismissedHints = new List<string>();
|
||||
public bool AllTutorialsDisabled;
|
||||
|
||||
// Runtime sets for faster lookup (populated from lists on load)
|
||||
[NonSerialized]
|
||||
private HashSet<string> _completedTutorialsSet;
|
||||
[NonSerialized]
|
||||
private HashSet<string> _dismissedHintsSet;
|
||||
|
||||
/// <summary>
|
||||
/// Loads tutorial state from PlayerPrefs, or creates new state if none exists.
|
||||
/// </summary>
|
||||
public static TutorialState Load() {
|
||||
if (!PlayerPrefs.HasKey(PrefsKey)) {
|
||||
Debug.Log("TutorialState: No saved state found, creating new");
|
||||
return new TutorialState();
|
||||
}
|
||||
|
||||
try {
|
||||
string json = PlayerPrefs.GetString(PrefsKey);
|
||||
var state = JsonUtility.FromJson<TutorialState>(json);
|
||||
|
||||
// Handle version migration if needed
|
||||
if (state.Version < CurrentVersion) { state = MigrateState(state); }
|
||||
|
||||
state.BuildRuntimeSets();
|
||||
Debug.Log(
|
||||
$"TutorialState: Loaded state - Onboarding: {state.OnboardingCompleted}, Completed: {state.CompletedTutorials.Count}");
|
||||
return state;
|
||||
} catch (Exception e) {
|
||||
Debug.LogWarning(
|
||||
$"TutorialState: Failed to load state, creating new. Error: {e.Message}");
|
||||
return new TutorialState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves current state to PlayerPrefs.
|
||||
/// </summary>
|
||||
public void Save() {
|
||||
try {
|
||||
string json = JsonUtility.ToJson(this);
|
||||
PlayerPrefs.SetString(PrefsKey, json);
|
||||
PlayerPrefs.Save();
|
||||
Debug.Log("TutorialState: State saved");
|
||||
} catch (Exception e) {
|
||||
Debug.LogError($"TutorialState: Failed to save state. Error: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all tutorial state (for testing or user request).
|
||||
/// </summary>
|
||||
public void Reset() {
|
||||
OnboardingCompleted = false;
|
||||
OnboardingStepReached = 0;
|
||||
CompletedTutorials.Clear();
|
||||
DismissedHints.Clear();
|
||||
AllTutorialsDisabled = false;
|
||||
BuildRuntimeSets();
|
||||
Save();
|
||||
Debug.Log("TutorialState: State reset");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a specific tutorial as completed.
|
||||
/// </summary>
|
||||
public void MarkTutorialCompleted(string tutorialId) {
|
||||
if (string.IsNullOrEmpty(tutorialId)) return;
|
||||
|
||||
EnsureRuntimeSets();
|
||||
if (_completedTutorialsSet.Add(tutorialId)) {
|
||||
CompletedTutorials.Add(tutorialId);
|
||||
Save();
|
||||
Debug.Log($"TutorialState: Marked tutorial '{tutorialId}' as completed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a hint as dismissed (won't show again).
|
||||
/// </summary>
|
||||
public void MarkHintDismissed(string hintId) {
|
||||
if (string.IsNullOrEmpty(hintId)) return;
|
||||
|
||||
EnsureRuntimeSets();
|
||||
if (_dismissedHintsSet.Add(hintId)) {
|
||||
DismissedHints.Add(hintId);
|
||||
Save();
|
||||
Debug.Log($"TutorialState: Marked hint '{hintId}' as dismissed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a tutorial has been completed.
|
||||
/// </summary>
|
||||
public bool HasCompletedTutorial(string tutorialId) {
|
||||
if (string.IsNullOrEmpty(tutorialId)) return false;
|
||||
EnsureRuntimeSets();
|
||||
return _completedTutorialsSet.Contains(tutorialId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a hint has been dismissed.
|
||||
/// </summary>
|
||||
public bool HasDismissedHint(string hintId) {
|
||||
if (string.IsNullOrEmpty(hintId)) return false;
|
||||
EnsureRuntimeSets();
|
||||
return _dismissedHintsSet.Contains(hintId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the onboarding as completed.
|
||||
/// </summary>
|
||||
public void CompleteOnboarding() {
|
||||
OnboardingCompleted = true;
|
||||
Save();
|
||||
Debug.Log("TutorialState: Onboarding completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the furthest onboarding step reached.
|
||||
/// </summary>
|
||||
public void SetOnboardingStep(int step) {
|
||||
if (step > OnboardingStepReached) {
|
||||
OnboardingStepReached = step;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables all tutorials (user preference).
|
||||
/// </summary>
|
||||
public void DisableAllTutorials() {
|
||||
AllTutorialsDisabled = true;
|
||||
Save();
|
||||
Debug.Log("TutorialState: All tutorials disabled");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-enables tutorials.
|
||||
/// </summary>
|
||||
public void EnableTutorials() {
|
||||
AllTutorialsDisabled = false;
|
||||
Save();
|
||||
Debug.Log("TutorialState: Tutorials re-enabled");
|
||||
}
|
||||
|
||||
private void BuildRuntimeSets() {
|
||||
_completedTutorialsSet = new HashSet<string>(CompletedTutorials ?? new List<string>());
|
||||
_dismissedHintsSet = new HashSet<string>(DismissedHints ?? new List<string>());
|
||||
}
|
||||
|
||||
private void EnsureRuntimeSets() {
|
||||
if (_completedTutorialsSet == null || _dismissedHintsSet == null) {
|
||||
BuildRuntimeSets();
|
||||
}
|
||||
}
|
||||
|
||||
private static TutorialState MigrateState(TutorialState oldState) {
|
||||
// Future: Handle migrations between versions
|
||||
Debug.Log(
|
||||
$"TutorialState: Migrating from version {oldState.Version} to {CurrentVersion}");
|
||||
oldState.Version = CurrentVersion;
|
||||
return oldState;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2424323130a24ac381f535ed932c34d3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a53ad5e16bc649fe9344b1a1c700dbd8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Small pulsing dot indicator for subtle hints.
|
||||
/// Attaches to UI elements to draw attention without interrupting gameplay.
|
||||
/// Shows tooltip on hover, triggers full tutorial on click.
|
||||
/// </summary>
|
||||
public class TutorialHintIndicator : MonoBehaviour,
|
||||
IPointerEnterHandler,
|
||||
IPointerExitHandler,
|
||||
IPointerClickHandler {
|
||||
[Header("Appearance")]
|
||||
[Tooltip("The dot/indicator image")]
|
||||
public Image DotImage;
|
||||
|
||||
[Tooltip("Normal color of the dot")]
|
||||
public Color NormalColor = new Color(1f, 0.8f, 0f, 0.9f);
|
||||
|
||||
[Tooltip("Pulse highlight color")]
|
||||
public Color PulseColor = new Color(1f, 1f, 0.5f, 1f);
|
||||
|
||||
[Tooltip("Size of the indicator")]
|
||||
public float Size = 20f;
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Pulse animation period in seconds")]
|
||||
public float PulsePeriod = 1.0f;
|
||||
|
||||
[Tooltip("Pulse scale multiplier")]
|
||||
public float PulseScale = 1.3f;
|
||||
|
||||
// Runtime state
|
||||
[HideInInspector]
|
||||
public string HintId;
|
||||
|
||||
private string _tooltipText;
|
||||
private Transform _targetTransform;
|
||||
private RectTransform _rectTransform;
|
||||
private float _pulseTimer;
|
||||
private bool _isHovered;
|
||||
|
||||
private void Awake() {
|
||||
_rectTransform = GetComponent<RectTransform>();
|
||||
if (_rectTransform == null) {
|
||||
_rectTransform = gameObject.AddComponent<RectTransform>();
|
||||
}
|
||||
|
||||
// Set up image if not assigned
|
||||
if (DotImage == null) {
|
||||
DotImage = GetComponent<Image>();
|
||||
if (DotImage == null) { DotImage = gameObject.AddComponent<Image>(); }
|
||||
}
|
||||
|
||||
// Default to circle sprite or solid color
|
||||
DotImage.color = NormalColor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the hint indicator.
|
||||
/// </summary>
|
||||
public void Initialize(string hintId, Transform target, string tooltipText) {
|
||||
HintId = hintId;
|
||||
_targetTransform = target;
|
||||
_tooltipText = tooltipText;
|
||||
|
||||
// Set size
|
||||
_rectTransform.sizeDelta = new Vector2(Size, Size);
|
||||
|
||||
// Position near target
|
||||
UpdatePosition();
|
||||
}
|
||||
|
||||
private void Update() {
|
||||
// Pulse animation
|
||||
_pulseTimer += Time.deltaTime;
|
||||
float t = (Mathf.Sin(_pulseTimer * Mathf.PI * 2f / PulsePeriod) + 1f) / 2f;
|
||||
|
||||
if (!_isHovered) {
|
||||
// Pulse color
|
||||
DotImage.color = Color.Lerp(NormalColor, PulseColor, t);
|
||||
|
||||
// Pulse scale
|
||||
float scale = 1f + (PulseScale - 1f) * t;
|
||||
transform.localScale = Vector3.one * scale;
|
||||
}
|
||||
|
||||
// Follow target
|
||||
if (_targetTransform != null) { UpdatePosition(); }
|
||||
}
|
||||
|
||||
private void UpdatePosition() {
|
||||
if (_targetTransform == null) return;
|
||||
|
||||
RectTransform targetRect = _targetTransform as RectTransform;
|
||||
if (targetRect == null) { targetRect = _targetTransform.GetComponent<RectTransform>(); }
|
||||
|
||||
if (targetRect != null) {
|
||||
// Position at top-right corner of target
|
||||
Vector3[] corners = new Vector3[4];
|
||||
targetRect.GetWorldCorners(corners);
|
||||
Vector3 topRight = corners[2]; // Top-right corner
|
||||
|
||||
transform.position = topRight + new Vector3(-Size / 2, -Size / 2, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData) {
|
||||
_isHovered = true;
|
||||
|
||||
// Highlight
|
||||
DotImage.color = PulseColor;
|
||||
transform.localScale = Vector3.one * PulseScale;
|
||||
|
||||
// Show tooltip
|
||||
if (!string.IsNullOrEmpty(_tooltipText)) {
|
||||
// Find HoveringTooltip in scene and show text
|
||||
var tooltip = FindObjectOfType<HoveringTooltip>();
|
||||
if (tooltip != null) { tooltip.Text = _tooltipText; }
|
||||
}
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData) {
|
||||
_isHovered = false;
|
||||
|
||||
// Reset appearance (will resume pulsing in Update)
|
||||
|
||||
// Hide tooltip
|
||||
var tooltip = FindObjectOfType<HoveringTooltip>();
|
||||
if (tooltip != null) { tooltip.Text = ""; }
|
||||
}
|
||||
|
||||
public void OnPointerClick(PointerEventData eventData) {
|
||||
// Trigger the full tutorial for this hint
|
||||
TutorialManager.Instance?.TriggerContextualTutorial(HintId);
|
||||
|
||||
// Mark hint as dismissed
|
||||
TutorialManager.Instance?.State.MarkHintDismissed(HintId);
|
||||
|
||||
// Destroy this indicator
|
||||
Destroy(gameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3f3016726d6473da721d6e468baab8a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
using System;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Modal dialog panel for displaying tutorial content.
|
||||
/// Shows title, description, icon, and action buttons.
|
||||
/// </summary>
|
||||
public class TutorialModalPanel : MonoBehaviour {
|
||||
[Header("Content")]
|
||||
[Tooltip("Title text")]
|
||||
public TextMeshProUGUI TitleText;
|
||||
|
||||
[Tooltip("Description text")]
|
||||
public TextMeshProUGUI DescriptionText;
|
||||
|
||||
[Tooltip("Icon image")]
|
||||
public Image IconImage;
|
||||
|
||||
[Header("Buttons")]
|
||||
[Tooltip("Continue/Got it button")]
|
||||
public Button ContinueButton;
|
||||
|
||||
[Tooltip("Text on continue button")]
|
||||
public TextMeshProUGUI ContinueButtonText;
|
||||
|
||||
[Tooltip("Skip this step button")]
|
||||
public Button SkipButton;
|
||||
|
||||
[Tooltip("Skip all tutorials button (onboarding only)")]
|
||||
public Button SkipAllButton;
|
||||
|
||||
[Header("Progress")]
|
||||
[Tooltip("Progress text (e.g., 'Step 3 of 7')")]
|
||||
public TextMeshProUGUI ProgressText;
|
||||
|
||||
[Tooltip("Progress slider/bar")]
|
||||
public Slider ProgressSlider;
|
||||
|
||||
[Header("Background")]
|
||||
[Tooltip("Modal blocker/dimmer")]
|
||||
public GameObject ModalBlocker;
|
||||
|
||||
[Tooltip("Panel container")]
|
||||
public GameObject PanelContainer;
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Animator for panel animations")]
|
||||
public Animator PanelAnimator;
|
||||
|
||||
// Callbacks
|
||||
private Action _onContinue;
|
||||
private Action _onSkip;
|
||||
|
||||
private void Awake() {
|
||||
// Set up button listeners
|
||||
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
|
||||
if (SkipButton != null) { SkipButton.onClick.AddListener(OnSkipClicked); }
|
||||
if (SkipAllButton != null) { SkipAllButton.onClick.AddListener(OnSkipAllClicked); }
|
||||
|
||||
// Start hidden
|
||||
Hide();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the modal with tutorial step content.
|
||||
/// </summary>
|
||||
public void Show(
|
||||
TutorialStep step,
|
||||
int currentIndex,
|
||||
int totalSteps,
|
||||
Action onContinue,
|
||||
Action onSkip,
|
||||
bool isOnboarding) {
|
||||
_onContinue = onContinue;
|
||||
_onSkip = onSkip;
|
||||
|
||||
// Set content
|
||||
if (TitleText != null) { TitleText.text = step.Title ?? ""; }
|
||||
|
||||
if (DescriptionText != null) { DescriptionText.text = step.Description ?? ""; }
|
||||
|
||||
if (IconImage != null) {
|
||||
if (step.Icon != null) {
|
||||
IconImage.sprite = step.Icon;
|
||||
IconImage.gameObject.SetActive(true);
|
||||
} else {
|
||||
IconImage.gameObject.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Set button text
|
||||
if (ContinueButtonText != null) {
|
||||
ContinueButtonText.text = currentIndex >= totalSteps - 1 ? "Got it!" : "Continue";
|
||||
}
|
||||
|
||||
// Show/hide skip buttons
|
||||
if (SkipButton != null) { SkipButton.gameObject.SetActive(step.AllowSkip); }
|
||||
if (SkipAllButton != null) {
|
||||
SkipAllButton.gameObject.SetActive(isOnboarding && step.AllowSkip);
|
||||
}
|
||||
|
||||
// Set progress
|
||||
if (ProgressText != null) {
|
||||
ProgressText.text = $"Step {currentIndex + 1} of {totalSteps}";
|
||||
ProgressText.gameObject.SetActive(totalSteps > 1);
|
||||
}
|
||||
if (ProgressSlider != null) {
|
||||
ProgressSlider.value = (float)(currentIndex + 1) / totalSteps;
|
||||
ProgressSlider.gameObject.SetActive(totalSteps > 1);
|
||||
}
|
||||
|
||||
// Show panel
|
||||
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
|
||||
if (PanelContainer != null) { PanelContainer.SetActive(true); }
|
||||
gameObject.SetActive(true);
|
||||
|
||||
// Trigger animation
|
||||
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Show"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides the modal panel.
|
||||
/// </summary>
|
||||
public void Hide() {
|
||||
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Hide"); }
|
||||
|
||||
if (ModalBlocker != null) { ModalBlocker.SetActive(false); }
|
||||
if (PanelContainer != null) { PanelContainer.SetActive(false); }
|
||||
gameObject.SetActive(false);
|
||||
|
||||
_onContinue = null;
|
||||
_onSkip = null;
|
||||
}
|
||||
|
||||
private void OnContinueClicked() {
|
||||
var callback = _onContinue;
|
||||
Hide();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
private void OnSkipClicked() {
|
||||
var callback = _onSkip;
|
||||
Hide();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
private void OnSkipAllClicked() {
|
||||
Hide();
|
||||
TutorialManager.Instance?.SkipAllOnboarding();
|
||||
}
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 461d5eac2fdd4d509a15185a42677886
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-278
@@ -1,278 +0,0 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Semi-transparent overlay that highlights specific UI elements.
|
||||
/// Dims the rest of the screen and shows explanatory text.
|
||||
/// </summary>
|
||||
public class TutorialOverlayController : MonoBehaviour {
|
||||
[Header("Background")]
|
||||
[Tooltip("Semi-transparent background that dims the screen")]
|
||||
public Image BackgroundDimmer;
|
||||
|
||||
[Tooltip("Background dimmer alpha")]
|
||||
public float DimmerAlpha = 0.7f;
|
||||
|
||||
[Header("Highlight")]
|
||||
[Tooltip("The highlight frame/border around target element")]
|
||||
public RectTransform HighlightFrame;
|
||||
|
||||
[Tooltip("Padding around the highlighted element")]
|
||||
public float HighlightPadding = 10f;
|
||||
|
||||
[Tooltip("Highlight border color")]
|
||||
public Color HighlightColor = Color.yellow;
|
||||
|
||||
[Header("Tooltip")]
|
||||
[Tooltip("Container for tooltip content")]
|
||||
public RectTransform TooltipContainer;
|
||||
|
||||
[Tooltip("Tooltip title text")]
|
||||
public TextMeshProUGUI TooltipTitle;
|
||||
|
||||
[Tooltip("Tooltip description text")]
|
||||
public TextMeshProUGUI TooltipDescription;
|
||||
|
||||
[Tooltip("Arrow/pointer image")]
|
||||
public Image ArrowPointer;
|
||||
|
||||
[Tooltip("Continue button in tooltip")]
|
||||
public Button ContinueButton;
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Duration of fade in/out")]
|
||||
public float FadeDuration = 0.3f;
|
||||
|
||||
[Tooltip("Pulse animation period")]
|
||||
public float PulsePeriod = 1.5f;
|
||||
|
||||
[Tooltip("Pulse scale amount")]
|
||||
public float PulseAmount = 0.05f;
|
||||
|
||||
// State
|
||||
private Transform _currentTarget;
|
||||
private Action _onComplete;
|
||||
private Coroutine _pulseCoroutine;
|
||||
private CanvasGroup _canvasGroup;
|
||||
|
||||
private void Awake() {
|
||||
_canvasGroup = GetComponent<CanvasGroup>();
|
||||
if (_canvasGroup == null) { _canvasGroup = gameObject.AddComponent<CanvasGroup>(); }
|
||||
|
||||
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
|
||||
|
||||
// Start hidden
|
||||
gameObject.SetActive(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the overlay highlighting a target element.
|
||||
/// </summary>
|
||||
public void ShowOverlay(
|
||||
Transform target,
|
||||
string title,
|
||||
string description,
|
||||
Vector2 offset,
|
||||
bool pulsing,
|
||||
Action onComplete) {
|
||||
_currentTarget = target;
|
||||
_onComplete = onComplete;
|
||||
|
||||
// Set content
|
||||
if (TooltipTitle != null) {
|
||||
TooltipTitle.text = title ?? "";
|
||||
TooltipTitle.gameObject.SetActive(!string.IsNullOrEmpty(title));
|
||||
}
|
||||
if (TooltipDescription != null) { TooltipDescription.text = description ?? ""; }
|
||||
|
||||
// Position elements
|
||||
if (target != null) {
|
||||
PositionHighlight(target);
|
||||
PositionTooltip(target, offset);
|
||||
} else {
|
||||
// No target - center the tooltip
|
||||
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(false); }
|
||||
if (ArrowPointer != null) { ArrowPointer.gameObject.SetActive(false); }
|
||||
CenterTooltip();
|
||||
}
|
||||
|
||||
// Show
|
||||
gameObject.SetActive(true);
|
||||
StartCoroutine(FadeIn());
|
||||
|
||||
// Start pulsing if requested
|
||||
if (pulsing && target != null) { _pulseCoroutine = StartCoroutine(PulseHighlight()); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows only the highlight without tooltip (for emphasis).
|
||||
/// </summary>
|
||||
public void HighlightOnly(Transform target) {
|
||||
_currentTarget = target;
|
||||
|
||||
if (target != null) {
|
||||
PositionHighlight(target);
|
||||
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
|
||||
}
|
||||
|
||||
// Hide tooltip elements
|
||||
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
|
||||
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
|
||||
|
||||
gameObject.SetActive(true);
|
||||
_pulseCoroutine = StartCoroutine(PulseHighlight());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears highlight without hiding overlay.
|
||||
/// </summary>
|
||||
public void ClearHighlight() {
|
||||
if (_pulseCoroutine != null) {
|
||||
StopCoroutine(_pulseCoroutine);
|
||||
_pulseCoroutine = null;
|
||||
}
|
||||
|
||||
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(false); }
|
||||
|
||||
_currentTarget = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides the overlay completely.
|
||||
/// </summary>
|
||||
public void HideOverlay() {
|
||||
if (_pulseCoroutine != null) {
|
||||
StopCoroutine(_pulseCoroutine);
|
||||
_pulseCoroutine = null;
|
||||
}
|
||||
|
||||
StartCoroutine(FadeOut());
|
||||
}
|
||||
|
||||
private void PositionHighlight(Transform target) {
|
||||
if (HighlightFrame == null || target == null) return;
|
||||
|
||||
RectTransform targetRect = target as RectTransform;
|
||||
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
|
||||
|
||||
if (targetRect != null) {
|
||||
// Get target bounds in screen space
|
||||
Vector3[] corners = new Vector3[4];
|
||||
targetRect.GetWorldCorners(corners);
|
||||
|
||||
// Convert to canvas space
|
||||
Canvas canvas = GetComponentInParent<Canvas>();
|
||||
if (canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay) {
|
||||
Camera cam = canvas.worldCamera ?? Camera.main;
|
||||
for (int i = 0; i < 4; i++) { corners[i] = cam.WorldToScreenPoint(corners[i]); }
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
float minX = Mathf.Min(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
|
||||
float maxX = Mathf.Max(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
|
||||
float minY = Mathf.Min(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
|
||||
float maxY = Mathf.Max(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
|
||||
|
||||
// Position and size highlight
|
||||
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
|
||||
Vector2 size = new Vector2(
|
||||
maxX - minX + HighlightPadding * 2,
|
||||
maxY - minY + HighlightPadding * 2);
|
||||
|
||||
HighlightFrame.position = center;
|
||||
HighlightFrame.sizeDelta = size;
|
||||
HighlightFrame.gameObject.SetActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void PositionTooltip(Transform target, Vector2 offset) {
|
||||
if (TooltipContainer == null) return;
|
||||
|
||||
TooltipContainer.gameObject.SetActive(true);
|
||||
|
||||
if (target != null) {
|
||||
RectTransform targetRect = target as RectTransform;
|
||||
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
|
||||
|
||||
if (targetRect != null) {
|
||||
// Position tooltip near target with offset
|
||||
Vector3 targetPos = targetRect.position;
|
||||
TooltipContainer.position = targetPos + new Vector3(offset.x, offset.y, 0);
|
||||
|
||||
// Position arrow to point at target
|
||||
if (ArrowPointer != null) {
|
||||
ArrowPointer.gameObject.SetActive(true);
|
||||
// Arrow rotation based on relative position could be added here
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CenterTooltip() {
|
||||
if (TooltipContainer == null) return;
|
||||
|
||||
TooltipContainer.gameObject.SetActive(true);
|
||||
TooltipContainer.anchoredPosition = Vector2.zero;
|
||||
|
||||
if (ArrowPointer != null) { ArrowPointer.gameObject.SetActive(false); }
|
||||
}
|
||||
|
||||
private IEnumerator FadeIn() {
|
||||
float elapsed = 0f;
|
||||
while (elapsed < FadeDuration) {
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / FadeDuration;
|
||||
if (_canvasGroup != null) { _canvasGroup.alpha = t; }
|
||||
yield return null;
|
||||
}
|
||||
if (_canvasGroup != null) { _canvasGroup.alpha = 1f; }
|
||||
}
|
||||
|
||||
private IEnumerator FadeOut() {
|
||||
float elapsed = 0f;
|
||||
while (elapsed < FadeDuration) {
|
||||
elapsed += Time.deltaTime;
|
||||
float t = 1f - (elapsed / FadeDuration);
|
||||
if (_canvasGroup != null) { _canvasGroup.alpha = t; }
|
||||
yield return null;
|
||||
}
|
||||
|
||||
gameObject.SetActive(false);
|
||||
_currentTarget = null;
|
||||
_onComplete = null;
|
||||
}
|
||||
|
||||
private IEnumerator PulseHighlight() {
|
||||
if (HighlightFrame == null) yield break;
|
||||
|
||||
Vector3 baseScale = HighlightFrame.localScale;
|
||||
float elapsed = 0f;
|
||||
|
||||
while (true) {
|
||||
elapsed += Time.deltaTime;
|
||||
float t = (Mathf.Sin(elapsed * Mathf.PI * 2f / PulsePeriod) + 1f) / 2f;
|
||||
float scale = 1f + t * PulseAmount;
|
||||
HighlightFrame.localScale = baseScale * scale;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnContinueClicked() {
|
||||
var callback = _onComplete;
|
||||
HideOverlay();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
private void Update() {
|
||||
// Follow target if it moves
|
||||
if (_currentTarget != null && HighlightFrame != null &&
|
||||
HighlightFrame.gameObject.activeSelf) {
|
||||
PositionHighlight(_currentTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3cf75368a9b643a18f00ff43e51af7c3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Manages all tutorial UI elements and their presentation.
|
||||
/// Coordinates between modal dialogs, overlays, and hint indicators.
|
||||
/// </summary>
|
||||
public class TutorialUIManager : MonoBehaviour {
|
||||
[Header("UI Components")]
|
||||
[Tooltip("Modal dialog panel for important tutorials")]
|
||||
public TutorialModalPanel ModalPanel;
|
||||
|
||||
[Tooltip("Overlay controller for highlighting UI elements")]
|
||||
public TutorialOverlayController OverlayController;
|
||||
|
||||
[Tooltip("Prefab for hint indicator dots")]
|
||||
public TutorialHintIndicator HintIndicatorPrefab;
|
||||
|
||||
[Header("Canvas")]
|
||||
[Tooltip("Canvas for tutorial UI (should be above game UI)")]
|
||||
public Canvas TutorialCanvas;
|
||||
|
||||
// Active hints
|
||||
private Dictionary<string, TutorialHintIndicator> _activeHints =
|
||||
new Dictionary<string, TutorialHintIndicator>();
|
||||
|
||||
private void Awake() {
|
||||
// Ensure canvas is set up correctly
|
||||
if (TutorialCanvas != null) {
|
||||
TutorialCanvas.sortingOrder = 1000; // Above most game UI
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a modal dialog for a tutorial step.
|
||||
/// </summary>
|
||||
public void ShowModal(
|
||||
TutorialStep step,
|
||||
int currentIndex,
|
||||
int totalSteps,
|
||||
Action onComplete,
|
||||
Action onSkip,
|
||||
bool isOnboarding) {
|
||||
if (ModalPanel == null) {
|
||||
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned");
|
||||
onComplete?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows an overlay highlighting a specific UI element.
|
||||
/// </summary>
|
||||
public void ShowOverlay(TutorialStep step, Action onComplete) {
|
||||
if (OverlayController == null) {
|
||||
Debug.LogWarning("TutorialUIManager: No OverlayController assigned");
|
||||
onComplete?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find target element
|
||||
Transform target = null;
|
||||
if (!string.IsNullOrEmpty(step.TargetGameObjectPath)) {
|
||||
var targetObj = GameObject.Find(step.TargetGameObjectPath);
|
||||
if (targetObj != null) {
|
||||
target = targetObj.transform;
|
||||
} else {
|
||||
Debug.LogWarning(
|
||||
$"TutorialUIManager: Could not find target '{step.TargetGameObjectPath}'");
|
||||
}
|
||||
}
|
||||
|
||||
OverlayController.ShowOverlay(
|
||||
target,
|
||||
step.Title,
|
||||
step.Description,
|
||||
step.HighlightOffset,
|
||||
step.HighlightPulsing,
|
||||
onComplete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a tooltip near a target element.
|
||||
/// </summary>
|
||||
public void ShowTooltip(TutorialStep step, Action onComplete) {
|
||||
// For now, use overlay with smaller presentation
|
||||
// Could be enhanced to use a dedicated tooltip component
|
||||
ShowOverlay(step, onComplete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a hint indicator on a UI element.
|
||||
/// </summary>
|
||||
public void ShowHint(string hintId, string targetPath, string tooltipText) {
|
||||
if (HintIndicatorPrefab == null) {
|
||||
Debug.LogWarning("TutorialUIManager: No HintIndicatorPrefab assigned");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show duplicate hints
|
||||
if (_activeHints.ContainsKey(hintId)) return;
|
||||
|
||||
// Find target
|
||||
Transform target = null;
|
||||
if (!string.IsNullOrEmpty(targetPath)) {
|
||||
var targetObj = GameObject.Find(targetPath);
|
||||
if (targetObj != null) { target = targetObj.transform; }
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
Debug.LogWarning($"TutorialUIManager: Could not find hint target '{targetPath}'");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create hint indicator
|
||||
var hint = Instantiate(HintIndicatorPrefab, TutorialCanvas.transform);
|
||||
hint.Initialize(hintId, target, tooltipText);
|
||||
_activeHints[hintId] = hint;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides a specific hint.
|
||||
/// </summary>
|
||||
public void HideHint(string hintId) {
|
||||
if (_activeHints.TryGetValue(hintId, out var hint)) {
|
||||
if (hint != null) { Destroy(hint.gameObject); }
|
||||
_activeHints.Remove(hintId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides all active hints.
|
||||
/// </summary>
|
||||
public void HideAllHints() {
|
||||
foreach (var hint in _activeHints.Values) {
|
||||
if (hint != null) { Destroy(hint.gameObject); }
|
||||
}
|
||||
_activeHints.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hides all tutorial UI.
|
||||
/// </summary>
|
||||
public void HideAll() {
|
||||
ModalPanel?.Hide();
|
||||
OverlayController?.HideOverlay();
|
||||
// Note: Don't hide hints automatically - they persist until dismissed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highlights a specific UI element (without showing tutorial content).
|
||||
/// </summary>
|
||||
public void HighlightElement(string gameObjectPath) {
|
||||
if (OverlayController == null) return;
|
||||
|
||||
Transform target = null;
|
||||
if (!string.IsNullOrEmpty(gameObjectPath)) {
|
||||
var targetObj = GameObject.Find(gameObjectPath);
|
||||
if (targetObj != null) { target = targetObj.transform; }
|
||||
}
|
||||
|
||||
if (target != null) { OverlayController.HighlightOnly(target); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears any active highlight.
|
||||
/// </summary>
|
||||
public void ClearHighlight() { OverlayController?.ClearHighlight(); }
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 52abae862492497cb7a3f8c72f837b37
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+142
-3
@@ -1,5 +1,144 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace EagleInstaller {
|
||||
public class CredentialManager {
|
||||
public const string ServerUrl = "https://assets.eagle0.net";
|
||||
public class Credentials {
|
||||
public string Username { get; set; }
|
||||
public string Password { get; set; }
|
||||
public string ServerUrl { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public class CredentialManager {
|
||||
private const string RegistryKeyPath = @"SOFTWARE\Eagle0\Launcher";
|
||||
private const string UsernameValueName = "Username";
|
||||
private const string ServerUrlValueName = "ServerUrl";
|
||||
private const string CredentialFileName = "eagle0_credentials.dat";
|
||||
|
||||
private static readonly string CredentialFilePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"eagle0",
|
||||
CredentialFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Loads saved credentials. Returns null if no valid credentials are found.
|
||||
/// </summary>
|
||||
public static Credentials LoadCredentials() {
|
||||
try {
|
||||
// Try to load from registry (username and server URL)
|
||||
string username = null;
|
||||
string serverUrl = null;
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, false)) {
|
||||
if (key != null) {
|
||||
username = key.GetValue(UsernameValueName) as string;
|
||||
serverUrl = key.GetValue(ServerUrlValueName) as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(username)) { return null; }
|
||||
|
||||
// Try to load encrypted password from file
|
||||
string password = LoadEncryptedPassword();
|
||||
if (string.IsNullOrEmpty(password)) { return null; }
|
||||
|
||||
return new Credentials {
|
||||
Username = username,
|
||||
Password = password,
|
||||
ServerUrl = serverUrl ?? "https://eagle0.net"
|
||||
};
|
||||
} catch (Exception) {
|
||||
// If anything fails, return null to prompt for credentials
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves credentials to registry (username/server) and encrypted file (password).
|
||||
/// </summary>
|
||||
public static void SaveCredentials(Credentials credentials) {
|
||||
try {
|
||||
// Save username and server URL to registry
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(RegistryKeyPath)) {
|
||||
key.SetValue(UsernameValueName, credentials.Username);
|
||||
key.SetValue(ServerUrlValueName, credentials.ServerUrl);
|
||||
}
|
||||
|
||||
// Save encrypted password to file
|
||||
SaveEncryptedPassword(credentials.Password);
|
||||
} catch (Exception ex) {
|
||||
throw new Exception($"Failed to save credentials: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all saved credentials.
|
||||
/// </summary>
|
||||
public static void ClearCredentials() {
|
||||
try {
|
||||
// Clear registry entries
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true)) {
|
||||
if (key != null) {
|
||||
key.DeleteValue(UsernameValueName, false);
|
||||
key.DeleteValue(ServerUrlValueName, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete password file
|
||||
if (File.Exists(CredentialFilePath)) { File.Delete(CredentialFilePath); }
|
||||
} catch (Exception) {
|
||||
// Ignore errors when clearing
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoadEncryptedPassword() {
|
||||
try {
|
||||
if (!File.Exists(CredentialFilePath)) { return null; }
|
||||
|
||||
byte[] encryptedData = File.ReadAllBytes(CredentialFilePath);
|
||||
byte[] decryptedData = ProtectedData.Unprotect(
|
||||
encryptedData,
|
||||
null,
|
||||
DataProtectionScope.CurrentUser);
|
||||
return Encoding.UTF8.GetString(decryptedData);
|
||||
} catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
private static void SaveEncryptedPassword(string password) {
|
||||
try {
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CredentialFilePath));
|
||||
|
||||
byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
|
||||
byte[] encryptedData =
|
||||
ProtectedData.Protect(passwordBytes, null, DataProtectionScope.CurrentUser);
|
||||
File.WriteAllBytes(CredentialFilePath, encryptedData);
|
||||
} catch (Exception ex) {
|
||||
throw new Exception($"Failed to save encrypted password: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves credentials in the same format used by the Unity client for compatibility.
|
||||
/// </summary>
|
||||
public static void SaveCredentialsForUnityClient(Credentials credentials) {
|
||||
try {
|
||||
// Unity uses PlayerPrefs which on Windows maps to registry entries
|
||||
// We'll save to the same location the Unity client expects
|
||||
const string unityRegistryPath =
|
||||
@"SOFTWARE\Unity\UnityEditor\CompanyName\ProductName";
|
||||
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(unityRegistryPath)) {
|
||||
key.SetValue("urlKey", credentials.ServerUrl);
|
||||
key.SetValue("nameKey", credentials.Username);
|
||||
key.SetValue("passwordKey", credentials.Password);
|
||||
}
|
||||
} catch (Exception) {
|
||||
// If Unity credential sharing fails, continue anyway
|
||||
// The main launcher credentials are still saved
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
@@ -10,6 +13,9 @@ namespace EagleInstaller {
|
||||
internal class UpdaterFailureException
|
||||
(string message) : Exception(message);
|
||||
|
||||
internal class AuthenticationFailedException
|
||||
(string message) : Exception(message);
|
||||
|
||||
public class InstallerUpdateInfo {
|
||||
public bool InstallerNeedsUpdate { get; set; }
|
||||
public string NewInstallerVersion { get; set; }
|
||||
@@ -31,6 +37,8 @@ namespace EagleInstaller {
|
||||
private readonly HttpClient _httpClient;
|
||||
private static readonly SemaphoreSlim Pool = new(DownloadSlots, DownloadSlots);
|
||||
|
||||
// Current credentials and service URL
|
||||
private Credentials CurrentCredentials { get; set; }
|
||||
private Uri ServiceUrl { get; set; }
|
||||
|
||||
private struct FetchAttempt {
|
||||
@@ -74,12 +82,27 @@ namespace EagleInstaller {
|
||||
return dict;
|
||||
}
|
||||
|
||||
public EagleUpdater(string serverUrl) {
|
||||
if (string.IsNullOrEmpty(serverUrl)) {
|
||||
throw new ArgumentNullException(nameof(serverUrl));
|
||||
}
|
||||
ServiceUrl = new Uri(serverUrl);
|
||||
public EagleUpdater(Credentials credentials) {
|
||||
CurrentCredentials =
|
||||
credentials ?? throw new ArgumentNullException(nameof(credentials));
|
||||
ServiceUrl = new Uri(CurrentCredentials.ServerUrl);
|
||||
|
||||
_httpClient = new HttpClient();
|
||||
SetAuthentication(CurrentCredentials.Username, CurrentCredentials.Password);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the authentication credentials for future requests.
|
||||
/// </summary>
|
||||
public void UpdateCredentials(Credentials newCredentials) {
|
||||
CurrentCredentials = newCredentials;
|
||||
ServiceUrl = new Uri(CurrentCredentials.ServerUrl);
|
||||
SetAuthentication(CurrentCredentials.Username, CurrentCredentials.Password);
|
||||
}
|
||||
|
||||
private void SetAuthentication(string username, string password) {
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
AuthenticationHeader(username, password);
|
||||
}
|
||||
|
||||
private List<string> Discrepancies(
|
||||
@@ -233,6 +256,15 @@ namespace EagleInstaller {
|
||||
return existingFiles;
|
||||
}
|
||||
|
||||
private static AuthenticationHeaderValue AuthenticationHeader(
|
||||
string name,
|
||||
string password) {
|
||||
return new AuthenticationHeaderValue(
|
||||
"Basic",
|
||||
Convert.ToBase64String(
|
||||
System.Text.Encoding.ASCII.GetBytes($"{name}:{password}")));
|
||||
}
|
||||
|
||||
static async Task<string> LocalManifestText(string existingManifestPath) {
|
||||
if (!File.Exists(existingManifestPath)) return null;
|
||||
|
||||
@@ -243,6 +275,11 @@ namespace EagleInstaller {
|
||||
using HttpResponseMessage response =
|
||||
await _httpClient.GetAsync(new Uri(ServiceUrl, RemoteManifestName));
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized) {
|
||||
throw new AuthenticationFailedException(
|
||||
"Authentication failed while fetching manifest. Invalid username or password.");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using StreamReader reader = new StreamReader(responseStream);
|
||||
@@ -286,6 +323,11 @@ namespace EagleInstaller {
|
||||
using HttpResponseMessage response =
|
||||
await _httpClient.GetAsync(new Uri(ServiceUrl, RemotePrefix + remotePath));
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized) {
|
||||
throw new AuthenticationFailedException(
|
||||
$"Authentication failed while downloading {remotePath}. Invalid username or password.");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
|
||||
await using FileStream fileStream = File.Create(writePath);
|
||||
@@ -337,6 +379,11 @@ namespace EagleInstaller {
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
new Uri(ServiceUrl, "assets/" + installerUrl));
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized) {
|
||||
throw new AuthenticationFailedException(
|
||||
"Authentication failed while downloading new installer. Invalid username or password.");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
long? totalBytes = response.Content.Headers.ContentLength;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace EagleInstaller {
|
||||
public partial class LoginDialog : Form {
|
||||
public Credentials Credentials { get; private set; }
|
||||
public bool SaveCredentials { get; private set; }
|
||||
|
||||
private TextBox usernameTextBox;
|
||||
private TextBox passwordTextBox;
|
||||
private TextBox serverUrlTextBox;
|
||||
private CheckBox saveCredentialsCheckBox;
|
||||
private Button okButton;
|
||||
private Button cancelButton;
|
||||
private Label titleLabel;
|
||||
private Label usernameLabel;
|
||||
private Label passwordLabel;
|
||||
private Label serverLabel;
|
||||
|
||||
public LoginDialog(string title = "Eagle0 Login", Credentials existingCredentials = null) {
|
||||
InitializeComponent();
|
||||
this.Text = title;
|
||||
titleLabel.Text = title;
|
||||
|
||||
if (existingCredentials != null) {
|
||||
usernameTextBox.Text = existingCredentials.Username ?? "";
|
||||
passwordTextBox.Text = existingCredentials.Password ?? "";
|
||||
serverUrlTextBox.Text = existingCredentials.ServerUrl ?? "https://eagle0.net";
|
||||
} else {
|
||||
serverUrlTextBox.Text = "https://eagle0.net";
|
||||
}
|
||||
|
||||
// Default to saving credentials
|
||||
saveCredentialsCheckBox.Checked = true;
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
// Form properties
|
||||
this.Size = new Size(400, 280);
|
||||
this.StartPosition = FormStartPosition.CenterScreen;
|
||||
this.FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
this.MaximizeBox = false;
|
||||
this.MinimizeBox = false;
|
||||
this.ShowInTaskbar = false;
|
||||
|
||||
// Title label
|
||||
titleLabel = new Label {
|
||||
Text = "Eagle0 Login",
|
||||
Font = new Font("Microsoft Sans Serif", 12F, FontStyle.Bold),
|
||||
Location = new Point(20, 20),
|
||||
Size = new Size(350, 25),
|
||||
TextAlign = ContentAlignment.MiddleCenter
|
||||
};
|
||||
|
||||
// Username label and textbox
|
||||
usernameLabel = new Label {
|
||||
Text = "Username:",
|
||||
Location = new Point(20, 60),
|
||||
Size = new Size(80, 23),
|
||||
TextAlign = ContentAlignment.MiddleLeft
|
||||
};
|
||||
|
||||
usernameTextBox = new TextBox {
|
||||
Location = new Point(110, 60),
|
||||
Size = new Size(250, 23),
|
||||
TabIndex = 0
|
||||
};
|
||||
|
||||
// Password label and textbox
|
||||
passwordLabel = new Label {
|
||||
Text = "Password:",
|
||||
Location = new Point(20, 90),
|
||||
Size = new Size(80, 23),
|
||||
TextAlign = ContentAlignment.MiddleLeft
|
||||
};
|
||||
|
||||
passwordTextBox = new TextBox {
|
||||
Location = new Point(110, 90),
|
||||
Size = new Size(250, 23),
|
||||
UseSystemPasswordChar = true,
|
||||
TabIndex = 1
|
||||
};
|
||||
|
||||
// Server URL label and textbox
|
||||
serverLabel = new Label {
|
||||
Text = "Server:",
|
||||
Location = new Point(20, 120),
|
||||
Size = new Size(80, 23),
|
||||
TextAlign = ContentAlignment.MiddleLeft
|
||||
};
|
||||
|
||||
serverUrlTextBox = new TextBox {
|
||||
Location = new Point(110, 120),
|
||||
Size = new Size(250, 23),
|
||||
TabIndex = 2
|
||||
};
|
||||
|
||||
// Save credentials checkbox
|
||||
saveCredentialsCheckBox = new CheckBox {
|
||||
Text = "Remember credentials",
|
||||
Location = new Point(20, 160),
|
||||
Size = new Size(200, 23),
|
||||
TabIndex = 3
|
||||
};
|
||||
|
||||
// OK button
|
||||
okButton = new Button {
|
||||
Text = "OK",
|
||||
Location = new Point(200, 200),
|
||||
Size = new Size(80, 30),
|
||||
TabIndex = 4,
|
||||
DialogResult = DialogResult.OK
|
||||
};
|
||||
okButton.Click += OkButton_Click;
|
||||
|
||||
// Cancel button
|
||||
cancelButton = new Button {
|
||||
Text = "Cancel",
|
||||
Location = new Point(290, 200),
|
||||
Size = new Size(80, 30),
|
||||
TabIndex = 5,
|
||||
DialogResult = DialogResult.Cancel
|
||||
};
|
||||
|
||||
// Add controls to form
|
||||
this.Controls.AddRange(new Control[] {
|
||||
titleLabel,
|
||||
usernameLabel,
|
||||
usernameTextBox,
|
||||
passwordLabel,
|
||||
passwordTextBox,
|
||||
serverLabel,
|
||||
serverUrlTextBox,
|
||||
saveCredentialsCheckBox,
|
||||
okButton,
|
||||
cancelButton
|
||||
});
|
||||
|
||||
// Set default button and cancel button
|
||||
this.AcceptButton = okButton;
|
||||
this.CancelButton = cancelButton;
|
||||
|
||||
// Focus on username field
|
||||
this.ActiveControl = usernameTextBox;
|
||||
}
|
||||
|
||||
private void OkButton_Click(object sender, EventArgs e) {
|
||||
// Validate input
|
||||
if (string.IsNullOrWhiteSpace(usernameTextBox.Text)) {
|
||||
MessageBox.Show(
|
||||
"Username is required.",
|
||||
"Validation Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
usernameTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(passwordTextBox.Text)) {
|
||||
MessageBox.Show(
|
||||
"Password is required.",
|
||||
"Validation Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
passwordTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(serverUrlTextBox.Text)) {
|
||||
MessageBox.Show(
|
||||
"Server URL is required.",
|
||||
"Validation Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
serverUrlTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create credentials object
|
||||
Credentials = new Credentials {
|
||||
Username = usernameTextBox.Text.Trim(),
|
||||
Password = passwordTextBox.Text,
|
||||
ServerUrl = serverUrlTextBox.Text.Trim()
|
||||
};
|
||||
|
||||
// Normalize server URL
|
||||
if (!Credentials.ServerUrl.StartsWith("http://") &&
|
||||
!Credentials.ServerUrl.StartsWith("https://")) {
|
||||
Credentials.ServerUrl = "https://" + Credentials.ServerUrl;
|
||||
}
|
||||
|
||||
SaveCredentials = saveCredentialsCheckBox.Checked;
|
||||
|
||||
this.DialogResult = DialogResult.OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the login dialog and returns the entered credentials, or null if cancelled.
|
||||
/// </summary>
|
||||
public static (Credentials credentials, bool saveCredentials) ShowLoginDialog(
|
||||
string title = "Eagle0 Login",
|
||||
Credentials existingCredentials = null) {
|
||||
using (var dialog = new LoginDialog(title, existingCredentials)) {
|
||||
if (dialog.ShowDialog() == DialogResult.OK) {
|
||||
return (dialog.Credentials, dialog.SaveCredentials);
|
||||
}
|
||||
return (null, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,29 @@ using System.Windows.Forms;
|
||||
|
||||
namespace EagleInstaller {
|
||||
public partial class MainForm : Form {
|
||||
private Panel _loginPanel;
|
||||
private Panel _progressPanel;
|
||||
private TextBox _progressTextBox;
|
||||
private Label _statusLabel;
|
||||
private Button _changeCredentialsButton;
|
||||
private Button _exitButton;
|
||||
|
||||
public string ServerUrl => CredentialManager.ServerUrl;
|
||||
// Login controls
|
||||
private TextBox _usernameTextBox;
|
||||
private TextBox _passwordTextBox;
|
||||
private TextBox _serverUrlTextBox;
|
||||
private CheckBox _saveCredentialsCheckBox;
|
||||
private Button _loginButton;
|
||||
private Button _cancelButton;
|
||||
|
||||
public MainForm() { InitializeComponent(); }
|
||||
public Credentials EnteredCredentials { get; private set; }
|
||||
public bool SaveCredentials { get; private set; }
|
||||
public bool LoginCancelled { get; private set; }
|
||||
|
||||
public MainForm() {
|
||||
InitializeComponent();
|
||||
ShowLoginView();
|
||||
}
|
||||
|
||||
private void InitializeComponent() {
|
||||
// Form properties
|
||||
@@ -22,15 +37,114 @@ namespace EagleInstaller {
|
||||
this.MaximizeBox = false;
|
||||
this.MinimizeBox = true;
|
||||
|
||||
// Create login panel
|
||||
CreateLoginPanel();
|
||||
|
||||
// Create progress panel
|
||||
CreateProgressPanel();
|
||||
|
||||
// Add panel to form
|
||||
// Add panels to form
|
||||
this.Controls.Add(_loginPanel);
|
||||
this.Controls.Add(_progressPanel);
|
||||
}
|
||||
|
||||
private void CreateLoginPanel() {
|
||||
_loginPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(20) };
|
||||
|
||||
// Title
|
||||
var titleLabel = new Label {
|
||||
Text = "Eagle0 Login",
|
||||
Font = new Font("Microsoft Sans Serif", 14F, FontStyle.Bold),
|
||||
Location = new Point(20, 20),
|
||||
Size = new Size(540, 30),
|
||||
TextAlign = ContentAlignment.MiddleCenter
|
||||
};
|
||||
|
||||
// Username
|
||||
var usernameLabel = new Label {
|
||||
Text = "Username:",
|
||||
Location = new Point(50, 80),
|
||||
Size = new Size(100, 23)
|
||||
};
|
||||
_usernameTextBox = new TextBox {
|
||||
Location = new Point(160, 80),
|
||||
Size = new Size(300, 23),
|
||||
TabIndex = 0
|
||||
};
|
||||
|
||||
// Password
|
||||
var passwordLabel = new Label {
|
||||
Text = "Password:",
|
||||
Location = new Point(50, 110),
|
||||
Size = new Size(100, 23)
|
||||
};
|
||||
_passwordTextBox = new TextBox {
|
||||
Location = new Point(160, 110),
|
||||
Size = new Size(300, 23),
|
||||
UseSystemPasswordChar = true,
|
||||
TabIndex = 1
|
||||
};
|
||||
|
||||
// Server
|
||||
var serverLabel = new Label {
|
||||
Text = "Server:",
|
||||
Location = new Point(50, 140),
|
||||
Size = new Size(100, 23)
|
||||
};
|
||||
_serverUrlTextBox = new TextBox {
|
||||
Location = new Point(160, 140),
|
||||
Size = new Size(300, 23),
|
||||
TabIndex = 2
|
||||
};
|
||||
|
||||
// Save credentials checkbox
|
||||
_saveCredentialsCheckBox = new CheckBox {
|
||||
Text = "Remember credentials",
|
||||
Location = new Point(50, 180),
|
||||
Size = new Size(200, 23),
|
||||
Checked = true,
|
||||
TabIndex = 3
|
||||
};
|
||||
|
||||
// Buttons
|
||||
_loginButton = new Button {
|
||||
Text = "Login",
|
||||
Location = new Point(280, 220),
|
||||
Size = new Size(80, 30),
|
||||
TabIndex = 4
|
||||
};
|
||||
_loginButton.Click += LoginButton_Click;
|
||||
|
||||
_cancelButton = new Button {
|
||||
Text = "Cancel",
|
||||
Location = new Point(380, 220),
|
||||
Size = new Size(80, 30),
|
||||
TabIndex = 5
|
||||
};
|
||||
_cancelButton.Click += CancelButton_Click;
|
||||
|
||||
// Add controls to login panel
|
||||
_loginPanel.Controls.AddRange(new Control[] {
|
||||
titleLabel,
|
||||
usernameLabel,
|
||||
_usernameTextBox,
|
||||
passwordLabel,
|
||||
_passwordTextBox,
|
||||
serverLabel,
|
||||
_serverUrlTextBox,
|
||||
_saveCredentialsCheckBox,
|
||||
_loginButton,
|
||||
_cancelButton
|
||||
});
|
||||
|
||||
// Set form defaults
|
||||
this.AcceptButton = _loginButton;
|
||||
this.CancelButton = _cancelButton;
|
||||
}
|
||||
|
||||
private void CreateProgressPanel() {
|
||||
_progressPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10) };
|
||||
_progressPanel =
|
||||
new Panel { Dock = DockStyle.Fill, Padding = new Padding(10), Visible = false };
|
||||
|
||||
// Title and status
|
||||
var titleLabel = new Label {
|
||||
@@ -50,7 +164,7 @@ namespace EagleInstaller {
|
||||
// Progress text box with scrollbar
|
||||
_progressTextBox = new TextBox {
|
||||
Location = new Point(10, 75),
|
||||
Size = new Size(560, 310),
|
||||
Size = new Size(560, 280),
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Vertical,
|
||||
@@ -60,17 +174,52 @@ namespace EagleInstaller {
|
||||
TabIndex = 0
|
||||
};
|
||||
|
||||
// Exit button
|
||||
// Control buttons
|
||||
_changeCredentialsButton = new Button {
|
||||
Text = "Change Credentials",
|
||||
Location = new Point(350, 370),
|
||||
Size = new Size(130, 30),
|
||||
Enabled = false
|
||||
};
|
||||
_changeCredentialsButton.Click += ChangeCredentialsButton_Click;
|
||||
|
||||
_exitButton = new Button {
|
||||
Text = "Exit",
|
||||
Location = new Point(490, 395),
|
||||
Location = new Point(490, 370),
|
||||
Size = new Size(80, 30)
|
||||
};
|
||||
_exitButton.Click += ExitButton_Click;
|
||||
|
||||
// Add controls to progress panel
|
||||
_progressPanel.Controls.AddRange(
|
||||
new Control[] { titleLabel, _statusLabel, _progressTextBox, _exitButton });
|
||||
_progressPanel.Controls.AddRange(new Control[] {
|
||||
titleLabel,
|
||||
_statusLabel,
|
||||
_progressTextBox,
|
||||
_changeCredentialsButton,
|
||||
_exitButton
|
||||
});
|
||||
}
|
||||
|
||||
public void ShowLoginView() {
|
||||
_loginPanel.Visible = true;
|
||||
_progressPanel.Visible = false;
|
||||
this.ActiveControl = _usernameTextBox;
|
||||
}
|
||||
|
||||
public void ShowProgressView() {
|
||||
_loginPanel.Visible = false;
|
||||
_progressPanel.Visible = true;
|
||||
_changeCredentialsButton.Enabled = true;
|
||||
}
|
||||
|
||||
public void SetLoginCredentials(Credentials credentials) {
|
||||
if (credentials != null) {
|
||||
_usernameTextBox.Text = credentials.Username ?? "";
|
||||
_passwordTextBox.Text = credentials.Password ?? "";
|
||||
_serverUrlTextBox.Text = credentials.ServerUrl ?? "https://eagle0.net";
|
||||
} else {
|
||||
_serverUrlTextBox.Text = "https://eagle0.net";
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateStatus(string status) {
|
||||
@@ -92,6 +241,72 @@ namespace EagleInstaller {
|
||||
_progressTextBox.ScrollToCaret();
|
||||
}
|
||||
|
||||
private void LoginButton_Click(object sender, EventArgs e) {
|
||||
// Validate input
|
||||
if (string.IsNullOrWhiteSpace(_usernameTextBox.Text)) {
|
||||
MessageBox.Show(
|
||||
"Username is required.",
|
||||
"Validation Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
_usernameTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_passwordTextBox.Text)) {
|
||||
MessageBox.Show(
|
||||
"Password is required.",
|
||||
"Validation Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
_passwordTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_serverUrlTextBox.Text)) {
|
||||
MessageBox.Show(
|
||||
"Server URL is required.",
|
||||
"Validation Error",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
_serverUrlTextBox.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create credentials
|
||||
var serverUrl = _serverUrlTextBox.Text.Trim();
|
||||
if (!serverUrl.StartsWith("http://") && !serverUrl.StartsWith("https://")) {
|
||||
serverUrl = "https://" + serverUrl;
|
||||
}
|
||||
|
||||
EnteredCredentials = new Credentials {
|
||||
Username = _usernameTextBox.Text.Trim(),
|
||||
Password = _passwordTextBox.Text,
|
||||
ServerUrl = serverUrl
|
||||
};
|
||||
|
||||
SaveCredentials = _saveCredentialsCheckBox.Checked;
|
||||
LoginCancelled = false;
|
||||
|
||||
ShowProgressView();
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object sender, EventArgs e) {
|
||||
LoginCancelled = true;
|
||||
this.Close();
|
||||
}
|
||||
|
||||
private void ChangeCredentialsButton_Click(object sender, EventArgs e) { ShowLoginView(); }
|
||||
|
||||
private void ExitButton_Click(object sender, EventArgs e) { this.Close(); }
|
||||
|
||||
public static MainForm CreateMainForm(
|
||||
string title = "Eagle0 Login",
|
||||
Credentials existingCredentials = null) {
|
||||
var form = new MainForm();
|
||||
form.Text = title;
|
||||
form.SetLoginCredentials(existingCredentials);
|
||||
return form;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
@@ -11,8 +11,6 @@ namespace EagleInstaller {
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"eagle0");
|
||||
private static readonly string ExeLocation = Path.Combine(FileLocation, "eagle0.exe");
|
||||
private static readonly string InvitationFilePath =
|
||||
Path.Combine(FileLocation, "invitation.json");
|
||||
|
||||
private static void CleanupTempInstallerFiles() {
|
||||
try {
|
||||
@@ -23,34 +21,6 @@ namespace EagleInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractInvitationCode(string[] args) {
|
||||
foreach (string arg in args) {
|
||||
// Support --code=XXXX format
|
||||
if (arg.StartsWith("--code=", StringComparison.OrdinalIgnoreCase)) {
|
||||
return arg.Substring(7);
|
||||
}
|
||||
// Support -code=XXXX format
|
||||
if (arg.StartsWith("-code=", StringComparison.OrdinalIgnoreCase)) {
|
||||
return arg.Substring(6);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void SaveInvitationCode(string code) {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
Directory.CreateDirectory(FileLocation);
|
||||
|
||||
// Write simple JSON with the invitation code
|
||||
string json = $"{{\"invitation_code\":\"{code}\"}}";
|
||||
File.WriteAllText(InvitationFilePath, json);
|
||||
Logger.WriteLine($"Saved invitation code to {InvitationFilePath}");
|
||||
} catch (Exception ex) {
|
||||
Logger.WriteError($"Failed to save invitation code: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task Main(string[] args) {
|
||||
// Enable visual styles for Windows Forms
|
||||
Application.EnableVisualStyles();
|
||||
@@ -65,6 +35,14 @@ namespace EagleInstaller {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Shift key is held during launch to force login
|
||||
bool shiftKeyHeld = (Control.ModifierKeys & Keys.Shift) == Keys.Shift;
|
||||
|
||||
// Check for credential management flags
|
||||
bool forceLogin =
|
||||
shiftKeyHeld || (args.Length > 0 &&
|
||||
(args[0] == "--login" || args[0] == "--change-credentials"));
|
||||
bool clearCredentials = args.Length > 0 && args[0] == "--clear-credentials";
|
||||
bool showHelp = args.Length > 0 && (args[0] == "--help" || args[0] == "-h");
|
||||
|
||||
// Show help if requested
|
||||
@@ -73,11 +51,16 @@ namespace EagleInstaller {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract invitation code from args (if provided)
|
||||
string invitationCode = ExtractInvitationCode(args);
|
||||
// Handle clear credentials flag
|
||||
if (clearCredentials) {
|
||||
Console.WriteLine("Clearing saved credentials...");
|
||||
CredentialManager.ClearCredentials();
|
||||
Console.WriteLine("Credentials cleared successfully.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create main form with default server URL
|
||||
var mainForm = new MainForm();
|
||||
// Create main form
|
||||
var mainForm = MainForm.CreateMainForm();
|
||||
Logger.Initialize(mainForm);
|
||||
|
||||
// Show the form and ensure handle is created
|
||||
@@ -85,7 +68,7 @@ namespace EagleInstaller {
|
||||
mainForm.CreateControl();
|
||||
|
||||
// Start the installer logic in the background
|
||||
var installerTask = RunInstallerLogic(mainForm, invitationCode);
|
||||
var installerTask = RunInstallerLogic(mainForm, forceLogin);
|
||||
|
||||
// Run the application with the main form
|
||||
Application.Run(mainForm);
|
||||
@@ -94,54 +77,84 @@ namespace EagleInstaller {
|
||||
await installerTask;
|
||||
}
|
||||
|
||||
private static async Task RunInstallerLogic(MainForm mainForm, string invitationCode) {
|
||||
private static async Task RunInstallerLogic(MainForm mainForm, bool forceLogin) {
|
||||
try {
|
||||
string serverUrl = mainForm.ServerUrl;
|
||||
Logger.WriteLine($"Using server: {serverUrl}");
|
||||
|
||||
if (!string.IsNullOrEmpty(invitationCode)) {
|
||||
Logger.WriteLine("Invitation code provided");
|
||||
// Get credentials (load saved or prompt user)
|
||||
Credentials credentials = await GetValidCredentials(mainForm, forceLogin);
|
||||
if (credentials == null) {
|
||||
Logger.WriteError("No valid credentials provided. Exiting.");
|
||||
return;
|
||||
}
|
||||
|
||||
EagleUpdater updater = new EagleUpdater(serverUrl);
|
||||
// Switch to progress view
|
||||
SafeInvoke(mainForm, () => mainForm.ShowProgressView());
|
||||
|
||||
// Stage 1: Check if installer itself needs update
|
||||
var installerUpdateInfo = await updater.CheckForInstallerUpdate();
|
||||
if (installerUpdateInfo.InstallerNeedsUpdate) {
|
||||
Logger.UpdateStatus("Installer update required...");
|
||||
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
|
||||
installerUpdateInfo.NewInstallerUrl);
|
||||
if (downloadSuccess) {
|
||||
Logger.UpdateStatus("New installer launched. Exiting current version.");
|
||||
EagleUpdater updater = new EagleUpdater(credentials);
|
||||
|
||||
// Wait a moment to ensure new installer starts before we exit
|
||||
await Task.Delay(1000);
|
||||
bool credentialsAreValid = false;
|
||||
while (!credentialsAreValid) {
|
||||
try {
|
||||
// Stage 1: Check if installer itself needs update
|
||||
var installerUpdateInfo = await updater.CheckForInstallerUpdate();
|
||||
if (installerUpdateInfo.InstallerNeedsUpdate) {
|
||||
Logger.UpdateStatus("Installer update required...");
|
||||
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
|
||||
installerUpdateInfo.NewInstallerUrl);
|
||||
if (downloadSuccess) {
|
||||
Logger.UpdateStatus(
|
||||
"New installer launched. Exiting current version.");
|
||||
|
||||
// Exit the application directly
|
||||
Application.Exit();
|
||||
return; // Exit this version
|
||||
// Wait a moment to ensure new installer starts before we exit
|
||||
await Task.Delay(1000);
|
||||
|
||||
// Exit the application directly
|
||||
Application.Exit();
|
||||
return; // Exit this version
|
||||
}
|
||||
|
||||
Logger.WriteError(
|
||||
"Failed to download new installer, continuing with current version...");
|
||||
}
|
||||
|
||||
// Stage 2: Proceed with normal game update
|
||||
bool success = await updater.MaybePerformUpdateWithRetries(FileLocation);
|
||||
|
||||
if (success) {
|
||||
Logger.UpdateStatus("Launching Eagle0...");
|
||||
Process.Start(ExeLocation);
|
||||
|
||||
// Exit the installer after launching the game
|
||||
Application.Exit();
|
||||
return;
|
||||
} else {
|
||||
Logger.WriteError("Update failed!");
|
||||
credentialsAreValid = true; // Don't retry on non-auth failures
|
||||
}
|
||||
} catch (AuthenticationFailedException ex) {
|
||||
Logger.WriteError($"Authentication failed: {ex.Message}");
|
||||
|
||||
// Clear saved credentials since they're invalid
|
||||
CredentialManager.ClearCredentials();
|
||||
|
||||
// Show login view again
|
||||
SafeInvoke(mainForm, () => mainForm.ShowLoginView());
|
||||
|
||||
// Wait for new credentials
|
||||
credentials = await GetValidCredentials(
|
||||
mainForm,
|
||||
true,
|
||||
"Authentication Failed - Please re-enter credentials");
|
||||
if (credentials == null) {
|
||||
Logger.WriteError("No valid credentials provided. Exiting.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch back to progress view
|
||||
SafeInvoke(mainForm, () => mainForm.ShowProgressView());
|
||||
|
||||
// Update updater with new credentials
|
||||
updater.UpdateCredentials(credentials);
|
||||
}
|
||||
|
||||
Logger.WriteError(
|
||||
"Failed to download new installer, continuing with current version...");
|
||||
}
|
||||
|
||||
// Stage 2: Proceed with normal game update
|
||||
bool success = await updater.MaybePerformUpdateWithRetries(FileLocation);
|
||||
|
||||
if (success) {
|
||||
// Save invitation code before launching game (if provided)
|
||||
if (!string.IsNullOrEmpty(invitationCode)) {
|
||||
SaveInvitationCode(invitationCode);
|
||||
}
|
||||
|
||||
Logger.UpdateStatus("Launching Eagle0...");
|
||||
Process.Start(ExeLocation);
|
||||
|
||||
// Exit the installer after launching the game
|
||||
Application.Exit();
|
||||
} else {
|
||||
Logger.WriteError("Update failed!");
|
||||
}
|
||||
} catch (Exception ex) { Logger.WriteError($"Unexpected error: {ex.Message}"); }
|
||||
}
|
||||
@@ -278,6 +291,88 @@ namespace EagleInstaller {
|
||||
} finally { progressForm?.Close(); }
|
||||
}
|
||||
|
||||
private static async Task<Credentials> GetValidCredentials(
|
||||
MainForm mainForm,
|
||||
bool forceLogin = false,
|
||||
string title = "Eagle0 Login") {
|
||||
Credentials savedCredentials = null;
|
||||
|
||||
// Try to load saved credentials first (unless forced to login)
|
||||
if (!forceLogin) {
|
||||
savedCredentials = CredentialManager.LoadCredentials();
|
||||
|
||||
if (savedCredentials != null) {
|
||||
Logger.WriteLine(
|
||||
$"Found saved credentials for user: {savedCredentials.Username}");
|
||||
// Return saved credentials immediately for automatic login
|
||||
return savedCredentials;
|
||||
}
|
||||
}
|
||||
|
||||
// If no saved credentials or force login, show login form
|
||||
Logger.WriteLine("No saved credentials found. Please enter your login information.");
|
||||
|
||||
// Set the form title and pre-fill credentials if forcing login
|
||||
SafeInvoke(mainForm, () => {
|
||||
mainForm.Text = title;
|
||||
if (forceLogin && savedCredentials != null) {
|
||||
mainForm.SetLoginCredentials(savedCredentials);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for user to enter credentials through the form
|
||||
return await WaitForCredentials(mainForm);
|
||||
}
|
||||
|
||||
private static async Task<Credentials> WaitForCredentials(MainForm mainForm) {
|
||||
var tcs = new TaskCompletionSource<Credentials>();
|
||||
|
||||
// Set up event handler to capture when login is completed
|
||||
EventHandler loginCompleted = null;
|
||||
loginCompleted = (sender, e) => {
|
||||
var form = sender as MainForm;
|
||||
if (form is { EnteredCredentials : not null }) {
|
||||
var credentials = form.EnteredCredentials;
|
||||
var saveCredentials = form.SaveCredentials;
|
||||
|
||||
if (saveCredentials) {
|
||||
try {
|
||||
CredentialManager.SaveCredentials(credentials);
|
||||
CredentialManager.SaveCredentialsForUnityClient(credentials);
|
||||
Logger.WriteLine("Credentials saved successfully.");
|
||||
} catch (Exception ex) {
|
||||
Logger.WriteError($"Failed to save credentials: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
tcs.SetResult(credentials);
|
||||
} else if (form != null && form.LoginCancelled) {
|
||||
tcs.SetResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Monitor for form changes (this is a simplified approach)
|
||||
var monitorTask = Task.Run(async () => {
|
||||
while (!tcs.Task.IsCompleted) {
|
||||
await Task.Delay(100);
|
||||
if (mainForm.EnteredCredentials != null || mainForm.LoginCancelled) {
|
||||
loginCompleted(mainForm, EventArgs.Empty);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
private static void SafeInvoke(Control control, Action action) {
|
||||
if (control.IsHandleCreated && control.InvokeRequired) {
|
||||
control.Invoke(action);
|
||||
} else if (control.IsHandleCreated) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowHelp() {
|
||||
Console.WriteLine("Eagle0 Installer");
|
||||
Console.WriteLine();
|
||||
@@ -285,12 +380,19 @@ namespace EagleInstaller {
|
||||
Console.WriteLine(" EagleInstaller.exe [OPTIONS]");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("OPTIONS:");
|
||||
Console.WriteLine(" --help, -h Show this help message");
|
||||
Console.WriteLine(" --code=CODE Invitation code for new account registration");
|
||||
Console.WriteLine(" --help, -h Show this help message");
|
||||
Console.WriteLine(" --login, --change-credentials Force credential input dialog");
|
||||
Console.WriteLine(" --clear-credentials Clear saved credentials and exit");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("NORMAL OPERATION:");
|
||||
Console.WriteLine(" Run without arguments to start the normal update process.");
|
||||
Console.WriteLine(" Downloads game files from assets.eagle0.net CDN.");
|
||||
Console.WriteLine(" If credentials are saved, automatic login will occur.");
|
||||
Console.WriteLine(" Hold Shift during launch to force credential input dialog.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("EXAMPLES:");
|
||||
Console.WriteLine(" EagleInstaller.exe # Normal operation");
|
||||
Console.WriteLine(" EagleInstaller.exe --login # Force new login");
|
||||
Console.WriteLine(" EagleInstaller.exe --clear-credentials # Clear saved login");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
manifest_name = eagle0_manifest.txt
|
||||
remote_manifest_prefix = installer/
|
||||
remote_asset_prefix = unity3d/win/
|
||||
remote_manifest_prefix = assets/installer/
|
||||
remote_asset_prefix = assets/unity3d/win/
|
||||
|
||||
@@ -65,18 +65,16 @@ var (
|
||||
grpcClient eagle.EagleClient
|
||||
adminClient adminpb.AdminClient
|
||||
authClient authpb.AuthClient
|
||||
gamesTemplate *template.Template
|
||||
gameDetailTemplate *template.Template
|
||||
historyRowsTemplate *template.Template
|
||||
actionDetailTemplate *template.Template
|
||||
gameStateTemplate *template.Template
|
||||
settingsTemplate *template.Template
|
||||
settingsRowsTemplate *template.Template
|
||||
usersTemplate *template.Template
|
||||
usersRowsTemplate *template.Template
|
||||
invitationsTemplate *template.Template
|
||||
invitationsRowsTemplate *template.Template
|
||||
loginTemplate *template.Template
|
||||
gamesTemplate *template.Template
|
||||
gameDetailTemplate *template.Template
|
||||
historyRowsTemplate *template.Template
|
||||
actionDetailTemplate *template.Template
|
||||
gameStateTemplate *template.Template
|
||||
settingsTemplate *template.Template
|
||||
settingsRowsTemplate *template.Template
|
||||
usersTemplate *template.Template
|
||||
usersRowsTemplate *template.Template
|
||||
loginTemplate *template.Template
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -129,16 +127,6 @@ func main() {
|
||||
log.Fatalf("Failed to parse users rows template: %v", err)
|
||||
}
|
||||
|
||||
invitationsTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/invitations.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse invitations template: %v", err)
|
||||
}
|
||||
|
||||
invitationsRowsTemplate, err = template.ParseFS(templatesFS, "templates/invitations_rows.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse invitations rows template: %v", err)
|
||||
}
|
||||
|
||||
loginTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/login.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse login template: %v", err)
|
||||
@@ -197,8 +185,6 @@ func main() {
|
||||
http.HandleFunc("/settings/", requireAuth(handleSettingsRoutes))
|
||||
http.HandleFunc("/users", requireAuth(handleUsersPage))
|
||||
http.HandleFunc("/users/", requireAuth(handleUserRoutes))
|
||||
http.HandleFunc("/invitations", requireAuth(handleInvitationsPage))
|
||||
http.HandleFunc("/invitations/", requireAuth(handleInvitationRoutes))
|
||||
http.HandleFunc("/login", handleLoginPage)
|
||||
http.HandleFunc("/login/start", handleLoginStart)
|
||||
http.HandleFunc("/login/complete", handleLoginComplete)
|
||||
@@ -1818,329 +1804,6 @@ func handleSetUserAdmin(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
fmt.Fprintf(w, `<div class="alert alert-success">Admin status %s</div>`, status)
|
||||
}
|
||||
|
||||
// Invitation management handlers
|
||||
|
||||
// InvitationInfo represents an invitation for display
|
||||
type InvitationInfo struct {
|
||||
InvitationCode string
|
||||
InvitationCodeShort string
|
||||
Email string
|
||||
StatusName string
|
||||
CreatedByUserID string
|
||||
CreatedByDisplayName string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
RedeemedByUserID string
|
||||
RedeemedByDisplayName string
|
||||
}
|
||||
|
||||
// InvitationsPageData is the data for the invitations page
|
||||
type InvitationsPageData struct {
|
||||
Title string
|
||||
Invitations []InvitationInfo
|
||||
EmailFilter string
|
||||
StatusFilter int
|
||||
TotalCount int
|
||||
BuildInfo BuildInfo
|
||||
}
|
||||
|
||||
// InvitationsRowsData is the data for the invitations rows partial
|
||||
type InvitationsRowsData struct {
|
||||
Invitations []InvitationInfo
|
||||
EmailFilter string
|
||||
StatusFilter int
|
||||
HasMore bool
|
||||
NextOffset int
|
||||
}
|
||||
|
||||
func statusToName(status adminpb.InvitationStatus) string {
|
||||
switch status {
|
||||
case adminpb.InvitationStatus_INVITATION_STATUS_PENDING:
|
||||
return "pending"
|
||||
case adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
|
||||
return "redeemed"
|
||||
case adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
|
||||
return "expired"
|
||||
case adminpb.InvitationStatus_INVITATION_STATUS_REVOKED:
|
||||
return "revoked"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func convertInvitationInfo(inv *adminpb.InvitationInfo) InvitationInfo {
|
||||
createdAt := ""
|
||||
if inv.CreatedAt != nil {
|
||||
createdAt = inv.CreatedAt.AsTime().Format("2006-01-02 15:04")
|
||||
}
|
||||
expiresAt := ""
|
||||
if inv.ExpiresAt != nil {
|
||||
expiresAt = inv.ExpiresAt.AsTime().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
shortCode := inv.InvitationCode
|
||||
if len(shortCode) > 8 {
|
||||
shortCode = shortCode[:8]
|
||||
}
|
||||
|
||||
return InvitationInfo{
|
||||
InvitationCode: inv.InvitationCode,
|
||||
InvitationCodeShort: shortCode,
|
||||
Email: inv.Email,
|
||||
StatusName: statusToName(inv.Status),
|
||||
CreatedByUserID: inv.CreatedByUserId,
|
||||
CreatedByDisplayName: inv.CreatedByDisplayName,
|
||||
CreatedAt: createdAt,
|
||||
ExpiresAt: expiresAt,
|
||||
RedeemedByUserID: inv.RedeemedByUserId,
|
||||
RedeemedByDisplayName: inv.RedeemedByDisplayName,
|
||||
}
|
||||
}
|
||||
|
||||
func handleInvitationsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
emailFilter := r.URL.Query().Get("filter")
|
||||
statusFilter := 0
|
||||
if s := r.URL.Query().Get("status_filter"); s != "" {
|
||||
if v, err := strconv.Atoi(s); err == nil {
|
||||
statusFilter = v
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := createAdminContext(r)
|
||||
defer cancel()
|
||||
|
||||
resp, err := adminClient.ListInvitations(ctx, &adminpb.ListInvitationsRequest{
|
||||
EmailFilter: emailFilter,
|
||||
StatusFilter: adminpb.InvitationStatus(statusFilter),
|
||||
Offset: 0,
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to list invitations: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to list invitations: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var invitations []InvitationInfo
|
||||
for _, inv := range resp.Invitations {
|
||||
invitations = append(invitations, convertInvitationInfo(inv))
|
||||
}
|
||||
|
||||
data := InvitationsPageData{
|
||||
Title: "Invitations",
|
||||
Invitations: invitations,
|
||||
EmailFilter: emailFilter,
|
||||
StatusFilter: statusFilter,
|
||||
TotalCount: int(resp.TotalCount),
|
||||
BuildInfo: getBuildInfo(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := invitationsTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
|
||||
log.Printf("Template error: %v", err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func handleInvitationRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/invitations/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
http.Redirect(w, r, "/invitations", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch parts[0] {
|
||||
case "search":
|
||||
handleInvitationsSearch(w, r)
|
||||
case "create":
|
||||
handleCreateInvitation(w, r)
|
||||
default:
|
||||
// Assume it's an invitation code with an action
|
||||
if len(parts) >= 2 {
|
||||
code := parts[0]
|
||||
action := parts[1]
|
||||
switch action {
|
||||
case "resend":
|
||||
handleResendInvitation(w, r, code)
|
||||
case "revoke":
|
||||
handleRevokeInvitation(w, r, code)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
} else {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleInvitationsSearch(w http.ResponseWriter, r *http.Request) {
|
||||
emailFilter := r.URL.Query().Get("filter")
|
||||
statusFilter := 0
|
||||
if s := r.URL.Query().Get("status_filter"); s != "" {
|
||||
if v, err := strconv.Atoi(s); err == nil {
|
||||
statusFilter = v
|
||||
}
|
||||
}
|
||||
offset := 0
|
||||
if o := r.URL.Query().Get("offset"); o != "" {
|
||||
if v, err := strconv.Atoi(o); err == nil {
|
||||
offset = v
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := createAdminContext(r)
|
||||
defer cancel()
|
||||
|
||||
resp, err := adminClient.ListInvitations(ctx, &adminpb.ListInvitationsRequest{
|
||||
EmailFilter: emailFilter,
|
||||
StatusFilter: adminpb.InvitationStatus(statusFilter),
|
||||
Offset: int32(offset),
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to list invitations: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to list invitations: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var invitations []InvitationInfo
|
||||
for _, inv := range resp.Invitations {
|
||||
invitations = append(invitations, convertInvitationInfo(inv))
|
||||
}
|
||||
|
||||
nextOffset := offset + len(invitations)
|
||||
hasMore := nextOffset < int(resp.TotalCount)
|
||||
|
||||
data := InvitationsRowsData{
|
||||
Invitations: invitations,
|
||||
EmailFilter: emailFilter,
|
||||
StatusFilter: statusFilter,
|
||||
HasMore: hasMore,
|
||||
NextOffset: nextOffset,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := invitationsRowsTemplate.Execute(w, data); err != nil {
|
||||
log.Printf("Template error: %v", err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateInvitation(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Invalid form data: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
email := r.FormValue("email")
|
||||
expiresInDays := int32(30)
|
||||
if e := r.FormValue("expires_in_days"); e != "" {
|
||||
if v, err := strconv.Atoi(e); err == nil {
|
||||
expiresInDays = int32(v)
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<div class="alert alert-error">Email address is required</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := createAdminContext(r)
|
||||
defer cancel()
|
||||
|
||||
resp, err := adminClient.CreateInvitation(ctx, &adminpb.CreateInvitationRequest{
|
||||
Email: email,
|
||||
ExpiresInDays: expiresInDays,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to create invitation: %v", err)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-success">Invitation created and sent to %s</div>`, email)
|
||||
}
|
||||
|
||||
func handleResendInvitation(w http.ResponseWriter, r *http.Request, code string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := createAdminContext(r)
|
||||
defer cancel()
|
||||
|
||||
resp, err := adminClient.ResendInvitation(ctx, &adminpb.ResendInvitationRequest{
|
||||
InvitationCode: code,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to resend invitation: %v", err)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<div class="alert alert-success">Invitation email resent</div>`)
|
||||
}
|
||||
|
||||
func handleRevokeInvitation(w http.ResponseWriter, r *http.Request, code string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := createAdminContext(r)
|
||||
defer cancel()
|
||||
|
||||
resp, err := adminClient.RevokeInvitation(ctx, &adminpb.RevokeInvitationRequest{
|
||||
InvitationCode: code,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to revoke invitation: %v", err)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<div class="alert alert-success">Invitation revoked</div>`)
|
||||
}
|
||||
|
||||
// Authentication handlers
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>Invitations</h1>
|
||||
<span>{{.TotalCount}} invitation{{if ne .TotalCount 1}}s{{end}}</span>
|
||||
</div>
|
||||
|
||||
<div class="invitation-controls">
|
||||
<div class="search-controls">
|
||||
<input type="text"
|
||||
name="filter"
|
||||
placeholder="Search by email..."
|
||||
value="{{.EmailFilter}}"
|
||||
hx-get="/invitations/search"
|
||||
hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
|
||||
hx-target="#invitations-table-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='status_filter']"
|
||||
class="search-input">
|
||||
<select name="status_filter"
|
||||
hx-get="/invitations/search"
|
||||
hx-trigger="change"
|
||||
hx-target="#invitations-table-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='filter']"
|
||||
class="status-filter">
|
||||
<option value="0" {{if eq .StatusFilter 0}}selected{{end}}>All Status</option>
|
||||
<option value="1" {{if eq .StatusFilter 1}}selected{{end}}>Pending</option>
|
||||
<option value="2" {{if eq .StatusFilter 2}}selected{{end}}>Redeemed</option>
|
||||
<option value="3" {{if eq .StatusFilter 3}}selected{{end}}>Expired</option>
|
||||
<option value="4" {{if eq .StatusFilter 4}}selected{{end}}>Revoked</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" onclick="openCreateModal()">Create Invitation</button>
|
||||
</div>
|
||||
|
||||
<div id="feedback" class="feedback-area"></div>
|
||||
|
||||
{{if .Invitations}}
|
||||
<table class="invitations-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Created By</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Redeemed By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="invitations-table-body" hx-trigger="invitationUpdated from:body" hx-get="/invitations/search?filter={{.EmailFilter}}&status_filter={{.StatusFilter}}" hx-swap="innerHTML">
|
||||
{{range .Invitations}}
|
||||
<tr class="invitation-row status-{{.StatusName}}" id="invitation-{{.InvitationCodeShort}}">
|
||||
<td class="invitation-email">{{.Email}}</td>
|
||||
<td class="invitation-status">
|
||||
<span class="status-badge {{.StatusName}}">{{.StatusName}}</span>
|
||||
</td>
|
||||
<td class="invitation-created-by">
|
||||
{{if .CreatedByDisplayName}}{{.CreatedByDisplayName}}{{else}}{{.CreatedByUserID}}{{end}}
|
||||
</td>
|
||||
<td class="invitation-created">{{.CreatedAt}}</td>
|
||||
<td class="invitation-expires">{{.ExpiresAt}}</td>
|
||||
<td class="invitation-redeemed-by">
|
||||
{{if .RedeemedByDisplayName}}{{.RedeemedByDisplayName}}{{else if .RedeemedByUserID}}{{.RedeemedByUserID}}{{else}}-{{end}}
|
||||
</td>
|
||||
<td class="invitation-actions">
|
||||
{{if eq .StatusName "pending"}}
|
||||
<button class="btn-small"
|
||||
hx-post="/invitations/{{.InvitationCode}}/resend"
|
||||
hx-target="#feedback"
|
||||
hx-swap="innerHTML">
|
||||
Resend
|
||||
</button>
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/invitations/{{.InvitationCode}}/revoke"
|
||||
hx-target="#feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to revoke this invitation?">
|
||||
Revoke
|
||||
</button>
|
||||
<button class="btn-small btn-secondary"
|
||||
onclick="copyInvitationCode('{{.InvitationCode}}')">
|
||||
Copy Code
|
||||
</button>
|
||||
{{else}}
|
||||
<span class="no-actions">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>No invitations found.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Create Invitation Modal -->
|
||||
<dialog id="create-modal">
|
||||
<article>
|
||||
<header>
|
||||
<h3>Create Invitation</h3>
|
||||
<button aria-label="Close" class="close" onclick="document.getElementById('create-modal').close()"></button>
|
||||
</header>
|
||||
<form id="create-form" hx-post="/invitations/create" hx-target="#create-feedback" hx-swap="innerHTML">
|
||||
<label for="create-email">Email Address</label>
|
||||
<input type="email" id="create-email" name="email" placeholder="user@example.com" required>
|
||||
|
||||
<label for="create-expires">Expires In (days)</label>
|
||||
<input type="number" id="create-expires" name="expires_in_days" value="30" min="1" max="365">
|
||||
<small>Default: 30 days</small>
|
||||
|
||||
<div id="create-feedback"></div>
|
||||
</form>
|
||||
<footer>
|
||||
<button type="button" class="secondary" onclick="document.getElementById('create-modal').close()">Cancel</button>
|
||||
<button type="submit" form="create-form">Create & Send</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function openCreateModal() {
|
||||
document.getElementById('create-email').value = '';
|
||||
document.getElementById('create-expires').value = '30';
|
||||
document.getElementById('create-feedback').innerHTML = '';
|
||||
document.getElementById('create-modal').showModal();
|
||||
}
|
||||
|
||||
function copyInvitationCode(code) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
document.getElementById('feedback').innerHTML = '<div class="alert alert-success">Invitation code copied to clipboard</div>';
|
||||
setTimeout(() => {
|
||||
document.getElementById('feedback').innerHTML = '';
|
||||
}, 3000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
document.getElementById('feedback').innerHTML = '<div class="alert alert-error">Failed to copy code</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Handle successful invitation creation
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.pathInfo.requestPath === '/invitations/create' && evt.detail.successful) {
|
||||
const response = evt.detail.xhr.responseText;
|
||||
if (response.includes('alert-success')) {
|
||||
// Close modal and refresh table
|
||||
setTimeout(() => {
|
||||
document.getElementById('create-modal').close();
|
||||
htmx.trigger(document.getElementById('invitations-table-body'), 'invitationUpdated');
|
||||
}, 1500);
|
||||
}
|
||||
} else if (evt.detail.pathInfo.requestPath.includes('/revoke') ||
|
||||
evt.detail.pathInfo.requestPath.includes('/resend')) {
|
||||
if (evt.detail.successful) {
|
||||
htmx.trigger(document.getElementById('invitations-table-body'), 'invitationUpdated');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.invitation-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.invitations-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.invitations-table th, .invitations-table td {
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.status-badge.redeemed {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.expired {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.revoked {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.invitation-row.status-redeemed,
|
||||
.invitation-row.status-expired,
|
||||
.invitation-row.status-revoked {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.invitation-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.no-actions {
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.feedback-area {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
#create-modal article {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
#create-modal small {
|
||||
display: block;
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
@@ -1,55 +0,0 @@
|
||||
{{range .Invitations}}
|
||||
<tr class="invitation-row status-{{.StatusName}}" id="invitation-{{.InvitationCodeShort}}">
|
||||
<td class="invitation-email">{{.Email}}</td>
|
||||
<td class="invitation-status">
|
||||
<span class="status-badge {{.StatusName}}">{{.StatusName}}</span>
|
||||
</td>
|
||||
<td class="invitation-created-by">
|
||||
{{if .CreatedByDisplayName}}{{.CreatedByDisplayName}}{{else}}{{.CreatedByUserID}}{{end}}
|
||||
</td>
|
||||
<td class="invitation-created">{{.CreatedAt}}</td>
|
||||
<td class="invitation-expires">{{.ExpiresAt}}</td>
|
||||
<td class="invitation-redeemed-by">
|
||||
{{if .RedeemedByDisplayName}}{{.RedeemedByDisplayName}}{{else if .RedeemedByUserID}}{{.RedeemedByUserID}}{{else}}-{{end}}
|
||||
</td>
|
||||
<td class="invitation-actions">
|
||||
{{if eq .StatusName "pending"}}
|
||||
<button class="btn-small"
|
||||
hx-post="/invitations/{{.InvitationCode}}/resend"
|
||||
hx-target="#feedback"
|
||||
hx-swap="innerHTML">
|
||||
Resend
|
||||
</button>
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/invitations/{{.InvitationCode}}/revoke"
|
||||
hx-target="#feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to revoke this invitation?">
|
||||
Revoke
|
||||
</button>
|
||||
<button class="btn-small btn-secondary"
|
||||
onclick="copyInvitationCode('{{.InvitationCode}}')">
|
||||
Copy Code
|
||||
</button>
|
||||
{{else}}
|
||||
<span class="no-actions">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="empty-state">No invitations found.</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if .HasMore}}
|
||||
<tr>
|
||||
<td colspan="7" class="load-more">
|
||||
<button hx-get="/invitations/search?filter={{.EmailFilter}}&status_filter={{.StatusFilter}}&offset={{.NextOffset}}"
|
||||
hx-target="closest tr"
|
||||
hx-swap="outerHTML"
|
||||
class="btn-small">
|
||||
Load More
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -14,7 +14,6 @@
|
||||
<li class="brand">Eagle Admin <span class="build-info">{{if .BuildInfo.GitCommit}}({{.BuildInfo.GitCommit}}{{if .BuildInfo.BuildTime}}, {{.BuildInfo.BuildTime}}{{end}}){{end}}</span></li>
|
||||
<li><a href="/">Games</a></li>
|
||||
<li><a href="/users">Users</a></li>
|
||||
<li><a href="/invitations">Invitations</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/health">Health</a></li>
|
||||
<li id="jfr-controls" hx-get="/jfr/status" hx-trigger="load" hx-swap="innerHTML">
|
||||
|
||||
@@ -5,12 +5,9 @@ go_library(
|
||||
srcs = [
|
||||
"admin_handlers.go",
|
||||
"handlers.go",
|
||||
"invitation_handlers.go",
|
||||
"invitations.go",
|
||||
"jwt.go",
|
||||
"main.go",
|
||||
"oauth.go",
|
||||
"sendgrid.go",
|
||||
"users.go",
|
||||
],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/authservice",
|
||||
|
||||
@@ -16,17 +16,13 @@ import (
|
||||
// AdminHandler implements the Admin gRPC service
|
||||
type AdminHandler struct {
|
||||
adminpb.UnimplementedAdminServer
|
||||
userService *UserService
|
||||
invitationService *InvitationService
|
||||
emailService *EmailService
|
||||
userService *UserService
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler
|
||||
func NewAdminHandler(userService *UserService, invitationService *InvitationService, emailService *EmailService) *AdminHandler {
|
||||
func NewAdminHandler(userService *UserService) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
userService: userService,
|
||||
invitationService: invitationService,
|
||||
emailService: emailService,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,182 +167,3 @@ func (h *AdminHandler) userToAdminInfo(user *userpb.User) *adminpb.AdminUserInfo
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// ============== Invitation Management ==============
|
||||
|
||||
// CreateInvitation creates a new invitation and sends email
|
||||
func (h *AdminHandler) CreateInvitation(ctx context.Context, req *adminpb.CreateInvitationRequest) (*adminpb.CreateInvitationResponse, error) {
|
||||
adminUserID, err := h.requireAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[Admin] CreateInvitation called by %s for email=%s", adminUserID, req.Email)
|
||||
|
||||
// Create invitation
|
||||
invitation, err := h.invitationService.CreateInvitation(req.Email, adminUserID, int(req.ExpiresInDays))
|
||||
if err != nil {
|
||||
return &adminpb.CreateInvitationResponse{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
if err := h.emailService.SendInvitation(invitation); err != nil {
|
||||
log.Printf("[Admin] Failed to send invitation email: %v", err)
|
||||
// Don't fail the request - invitation was created, just email failed
|
||||
// We can add a flag to indicate email send status if needed
|
||||
}
|
||||
|
||||
return &adminpb.CreateInvitationResponse{
|
||||
Success: true,
|
||||
Invitation: h.invitationToInfo(invitation),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListInvitations returns a paginated list of invitations
|
||||
func (h *AdminHandler) ListInvitations(ctx context.Context, req *adminpb.ListInvitationsRequest) (*adminpb.ListInvitationsResponse, error) {
|
||||
adminUserID, err := h.requireAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[Admin] ListInvitations called by %s, status=%v, email=%q, offset=%d, limit=%d",
|
||||
adminUserID, req.StatusFilter, req.EmailFilter, req.Offset, req.Limit)
|
||||
|
||||
// Convert admin proto status to internal proto status
|
||||
internalStatus := h.adminStatusToInternal(req.StatusFilter)
|
||||
|
||||
invitations, totalCount := h.invitationService.ListInvitations(
|
||||
internalStatus,
|
||||
req.EmailFilter,
|
||||
int(req.Offset),
|
||||
int(req.Limit),
|
||||
)
|
||||
|
||||
var result []*adminpb.InvitationInfo
|
||||
for _, inv := range invitations {
|
||||
result = append(result, h.invitationToInfo(inv))
|
||||
}
|
||||
|
||||
return &adminpb.ListInvitationsResponse{
|
||||
Invitations: result,
|
||||
TotalCount: int32(totalCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RevokeInvitation revokes an invitation
|
||||
func (h *AdminHandler) RevokeInvitation(ctx context.Context, req *adminpb.RevokeInvitationRequest) (*adminpb.RevokeInvitationResponse, error) {
|
||||
adminUserID, err := h.requireAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[Admin] RevokeInvitation called by %s for code=%s", adminUserID, req.InvitationCode[:8]+"...")
|
||||
|
||||
if err := h.invitationService.Revoke(req.InvitationCode); err != nil {
|
||||
return &adminpb.RevokeInvitationResponse{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &adminpb.RevokeInvitationResponse{
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResendInvitation resends the invitation email
|
||||
func (h *AdminHandler) ResendInvitation(ctx context.Context, req *adminpb.ResendInvitationRequest) (*adminpb.ResendInvitationResponse, error) {
|
||||
adminUserID, err := h.requireAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[Admin] ResendInvitation called by %s for code=%s", adminUserID, req.InvitationCode[:8]+"...")
|
||||
|
||||
// Find the invitation
|
||||
invitation := h.invitationService.FindByCode(req.InvitationCode)
|
||||
if invitation == nil {
|
||||
return &adminpb.ResendInvitationResponse{
|
||||
Success: false,
|
||||
ErrorMessage: "invitation not found",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check if invitation is still pending
|
||||
if !h.invitationService.IsValidCode(req.InvitationCode) {
|
||||
return &adminpb.ResendInvitationResponse{
|
||||
Success: false,
|
||||
ErrorMessage: "invitation is no longer valid (expired, redeemed, or revoked)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Resend email
|
||||
if err := h.emailService.SendInvitation(invitation); err != nil {
|
||||
return &adminpb.ResendInvitationResponse{
|
||||
Success: false,
|
||||
ErrorMessage: "failed to send email: " + err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &adminpb.ResendInvitationResponse{
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// invitationToInfo converts an internal Invitation to admin InvitationInfo
|
||||
func (h *AdminHandler) invitationToInfo(inv *userpb.Invitation) *adminpb.InvitationInfo {
|
||||
info := &adminpb.InvitationInfo{
|
||||
InvitationCode: inv.GetInvitationCode(),
|
||||
Email: inv.GetEmail(),
|
||||
CreatedByUserId: inv.GetCreatedByUserId(),
|
||||
CreatedAt: inv.GetCreatedAt(),
|
||||
ExpiresAt: inv.GetExpiresAt(),
|
||||
Status: h.internalStatusToAdmin(inv.GetStatus()),
|
||||
RedeemedByUserId: inv.GetRedeemedByUserId(),
|
||||
RedeemedAt: inv.GetRedeemedAt(),
|
||||
}
|
||||
|
||||
// Resolve display names
|
||||
if inv.GetCreatedByUserId() != "" {
|
||||
if user := h.userService.FindByUserID(inv.GetCreatedByUserId()); user != nil {
|
||||
info.CreatedByDisplayName = user.GetDisplayName()
|
||||
}
|
||||
}
|
||||
if inv.GetRedeemedByUserId() != "" {
|
||||
if user := h.userService.FindByUserID(inv.GetRedeemedByUserId()); user != nil {
|
||||
info.RedeemedByDisplayName = user.GetDisplayName()
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// Convert between admin and internal proto status enums
|
||||
func (h *AdminHandler) adminStatusToInternal(status adminpb.InvitationStatus) userpb.InvitationStatus {
|
||||
switch status {
|
||||
case adminpb.InvitationStatus_INVITATION_STATUS_PENDING:
|
||||
return userpb.InvitationStatus_INVITATION_STATUS_PENDING
|
||||
case adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
|
||||
return userpb.InvitationStatus_INVITATION_STATUS_REDEEMED
|
||||
case adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
|
||||
return userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
|
||||
case adminpb.InvitationStatus_INVITATION_STATUS_REVOKED:
|
||||
return userpb.InvitationStatus_INVITATION_STATUS_REVOKED
|
||||
default:
|
||||
return userpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) internalStatusToAdmin(status userpb.InvitationStatus) adminpb.InvitationStatus {
|
||||
switch status {
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
|
||||
return adminpb.InvitationStatus_INVITATION_STATUS_PENDING
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
|
||||
return adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
|
||||
return adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
|
||||
return adminpb.InvitationStatus_INVITATION_STATUS_REVOKED
|
||||
default:
|
||||
return adminpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,13 +12,6 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// isInvitationRequired returns true if new users must provide invitation codes.
|
||||
// Controlled by REQUIRE_INVITATION_CODE env var (defaults to false).
|
||||
func isInvitationRequired() bool {
|
||||
val := os.Getenv("REQUIRE_INVITATION_CODE")
|
||||
return val == "true" || val == "1"
|
||||
}
|
||||
|
||||
// extractAccessToken extracts the JWT access token from gRPC metadata
|
||||
func extractAccessToken(ctx context.Context) (string, error) {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
@@ -49,17 +41,15 @@ func extractAccessToken(ctx context.Context) (string, error) {
|
||||
// AuthHandler implements the Auth gRPC service
|
||||
type AuthHandler struct {
|
||||
auth.UnimplementedAuthServer
|
||||
oauthSvc *OAuthService
|
||||
userService *UserService
|
||||
invitationService *InvitationService
|
||||
oauthSvc *OAuthService
|
||||
userService *UserService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService, invitationService *InvitationService) *AuthHandler {
|
||||
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
oauthSvc: oauthSvc,
|
||||
userService: userService,
|
||||
invitationService: invitationService,
|
||||
oauthSvc: oauthSvc,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +57,7 @@ func NewAuthHandler(oauthSvc *OAuthService, userService *UserService, invitation
|
||||
func (h *AuthHandler) GetOAuthUrl(ctx context.Context, req *auth.GetOAuthUrlRequest) (*auth.GetOAuthUrlResponse, error) {
|
||||
provider := providerToString(req.Provider)
|
||||
|
||||
authURL, state, err := h.oauthSvc.GetAuthURL(provider, req.ReturnUrl, req.InvitationCode)
|
||||
authURL, state, err := h.oauthSvc.GetAuthURL(provider, req.ReturnUrl)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "failed to get auth URL: %v", err)
|
||||
}
|
||||
@@ -104,49 +94,13 @@ func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuth
|
||||
|
||||
// Success - find or create user locally
|
||||
if result.Success {
|
||||
// Check if user already exists (for grandfathering)
|
||||
existingUser := h.userService.FindByOAuthIdentity(result.Provider, result.UserInfo.ID)
|
||||
isNewUser := existingUser == nil
|
||||
|
||||
// For new users, validate invitation code (if feature is enabled)
|
||||
if isNewUser && isInvitationRequired() {
|
||||
invitationCode := result.InvitationCode
|
||||
|
||||
// If no invitation code provided, require one
|
||||
if invitationCode == "" {
|
||||
log.Printf("New user %s attempted login without invitation code", result.UserInfo.Email)
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_INVITATION_REQUIRED,
|
||||
ErrorMessage: "An invitation code is required to create a new account",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate invitation code (don't redeem yet - we'll do that after user creation succeeds)
|
||||
if !h.invitationService.IsValidCode(invitationCode) {
|
||||
log.Printf("New user %s provided invalid invitation code", result.UserInfo.Email)
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
|
||||
ErrorMessage: "Invalid or expired invitation code",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
user, wasCreated := h.userService.FindOrCreateUser(
|
||||
user, isNewUser := h.userService.FindOrCreateUser(
|
||||
result.Provider,
|
||||
result.UserInfo.ID,
|
||||
result.UserInfo.Email,
|
||||
result.UserInfo.AvatarURL,
|
||||
)
|
||||
|
||||
// If user was created (new user), redeem the invitation
|
||||
if wasCreated && result.InvitationCode != "" {
|
||||
if _, err := h.invitationService.ValidateAndRedeem(result.InvitationCode, user.UserId); err != nil {
|
||||
// Log the error but don't fail - user is already created
|
||||
log.Printf("Warning: failed to redeem invitation code for new user %s: %v", user.UserId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create JWT tokens
|
||||
accessToken, err := CreateAccessToken(user.UserId, user.DisplayName, user.IsAdmin)
|
||||
if err != nil {
|
||||
@@ -180,7 +134,7 @@ func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuth
|
||||
AvatarUrl: user.AvatarUrl,
|
||||
Provider: stringToProvider(result.Provider),
|
||||
},
|
||||
IsNewUser: wasCreated,
|
||||
IsNewUser: isNewUser,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
// Package main provides HTTP handlers for invitation landing pages.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
|
||||
)
|
||||
|
||||
// InvitationHTTPHandler handles HTTP requests for invitation pages
|
||||
type InvitationHTTPHandler struct {
|
||||
invitationService *InvitationService
|
||||
installerURL string
|
||||
}
|
||||
|
||||
// NewInvitationHTTPHandler creates a new invitation HTTP handler
|
||||
func NewInvitationHTTPHandler(invitationService *InvitationService) *InvitationHTTPHandler {
|
||||
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
|
||||
if installerURL == "" {
|
||||
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
|
||||
}
|
||||
return &InvitationHTTPHandler{
|
||||
invitationService: invitationService,
|
||||
installerURL: installerURL,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers the invitation HTTP routes
|
||||
func (h *InvitationHTTPHandler) RegisterRoutes() {
|
||||
http.HandleFunc("/invite/", h.handleInvite)
|
||||
}
|
||||
|
||||
// handleInvite routes requests to the appropriate handler
|
||||
func (h *InvitationHTTPHandler) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse path: /invite/{code} or /invite/{code}/install.bat
|
||||
path := strings.TrimPrefix(r.URL.Path, "/invite/")
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
http.Error(w, "Invalid invitation URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
code := parts[0]
|
||||
|
||||
if len(parts) == 2 && parts[1] == "install.bat" {
|
||||
h.handleInstallBat(w, r, code)
|
||||
} else if len(parts) == 1 {
|
||||
h.handleLandingPage(w, r, code)
|
||||
} else {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLandingPage serves the invitation landing page
|
||||
func (h *InvitationHTTPHandler) handleLandingPage(w http.ResponseWriter, r *http.Request, code string) {
|
||||
invitation := h.invitationService.FindByCode(code)
|
||||
|
||||
data := struct {
|
||||
Valid bool
|
||||
Code string
|
||||
ExpiresAt string
|
||||
ErrorMessage string
|
||||
InstallBatURL string
|
||||
InstallerURL string
|
||||
}{
|
||||
Code: code,
|
||||
InstallBatURL: fmt.Sprintf("/invite/%s/install.bat", code),
|
||||
InstallerURL: h.installerURL,
|
||||
}
|
||||
|
||||
if invitation == nil {
|
||||
data.Valid = false
|
||||
data.ErrorMessage = "This invitation code is invalid or does not exist."
|
||||
} else {
|
||||
switch invitation.Status {
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
|
||||
// Check if expired by date
|
||||
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
|
||||
data.Valid = false
|
||||
data.ErrorMessage = "This invitation has expired."
|
||||
} else {
|
||||
data.Valid = true
|
||||
if invitation.ExpiresAt != nil {
|
||||
data.ExpiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
|
||||
}
|
||||
}
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
|
||||
data.Valid = false
|
||||
data.ErrorMessage = "This invitation has already been used."
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
|
||||
data.Valid = false
|
||||
data.ErrorMessage = "This invitation has expired."
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
|
||||
data.Valid = false
|
||||
data.ErrorMessage = "This invitation has been revoked."
|
||||
default:
|
||||
data.Valid = false
|
||||
data.ErrorMessage = "This invitation is not valid."
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := landingPageTemplate.Execute(w, data); err != nil {
|
||||
log.Printf("[InviteHTTP] Failed to render landing page: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleInstallBat serves the Windows batch installer script
|
||||
func (h *InvitationHTTPHandler) handleInstallBat(w http.ResponseWriter, r *http.Request, code string) {
|
||||
// Validate the code exists and is valid
|
||||
if !h.invitationService.IsValidCode(code) {
|
||||
http.Error(w, "Invalid or expired invitation code", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate batch script
|
||||
script := fmt.Sprintf(`@echo off
|
||||
echo.
|
||||
echo ====================================
|
||||
echo Eagle0 Installer
|
||||
echo ====================================
|
||||
echo.
|
||||
echo Downloading Eagle0 installer...
|
||||
echo.
|
||||
|
||||
curl -L -o "%%TEMP%%\EagleInstaller.exe" "%s"
|
||||
if %%ERRORLEVEL%% neq 0 (
|
||||
echo.
|
||||
echo Failed to download installer. Please check your internet connection.
|
||||
echo You can also download manually from: %s
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Starting installer...
|
||||
echo.
|
||||
start "" "%%TEMP%%\EagleInstaller.exe" --code=%s
|
||||
|
||||
echo.
|
||||
echo The installer should now be running.
|
||||
echo You can close this window.
|
||||
echo.
|
||||
timeout /t 5
|
||||
`, h.installerURL, h.installerURL, code)
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-bat")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.bat")
|
||||
w.Write([]byte(script))
|
||||
|
||||
log.Printf("[InviteHTTP] Served install.bat for code %s...", code[:8])
|
||||
}
|
||||
|
||||
var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Eagle0 Invitation</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-top: 40px;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: #1a5f7a;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
.error-box {
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #c00;
|
||||
}
|
||||
.content {
|
||||
text-align: center;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: #1a5f7a;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin: 20px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.button:hover {
|
||||
background: #154d62;
|
||||
}
|
||||
.instructions {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: left;
|
||||
}
|
||||
.instructions h3 {
|
||||
margin-top: 0;
|
||||
color: #1a5f7a;
|
||||
}
|
||||
.instructions ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.instructions li {
|
||||
margin: 10px 0;
|
||||
}
|
||||
.code-box {
|
||||
background: #e9ecef;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.manual-section {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
.manual-section h3 {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.expires {
|
||||
color: #856404;
|
||||
background: #fff3cd;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">Eagle0</div>
|
||||
{{if .Valid}}
|
||||
<h1>You've been invited!</h1>
|
||||
{{else}}
|
||||
<h1>Invitation Error</h1>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Valid}}
|
||||
<div class="content">
|
||||
<p>You've been invited to play Eagle0, a strategic turn-based game with tactical hex-based combat.</p>
|
||||
|
||||
<a href="{{.InstallBatURL}}" class="button">Download & Install</a>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>How to install:</h3>
|
||||
<ol>
|
||||
<li>Click the "Download & Install" button above</li>
|
||||
<li>Open the downloaded <strong>eagle0-install.bat</strong> file</li>
|
||||
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
|
||||
<li>The installer will download and start automatically with your invitation code</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{{if .ExpiresAt}}
|
||||
<div class="expires">
|
||||
This invitation expires on {{.ExpiresAt}}.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="manual-section">
|
||||
<h3>Alternative: Manual Installation</h3>
|
||||
<p>If the automatic installer doesn't work, you can <a href="{{.InstallerURL}}">download the installer manually</a> and enter this code when prompted:</p>
|
||||
<div class="code-box">{{.Code}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="error-box">
|
||||
<p>{{.ErrorMessage}}</p>
|
||||
<p>Please contact the person who invited you for a new invitation.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2026 Eagle0</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
@@ -1,364 +0,0 @@
|
||||
// Package main provides invitation management for the auth service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
|
||||
)
|
||||
|
||||
const (
|
||||
// InvitationCodeLength is the number of random bytes for invitation codes
|
||||
InvitationCodeLength = 32
|
||||
// DefaultExpirationDays is the default number of days until invitation expires
|
||||
DefaultExpirationDays = 30
|
||||
)
|
||||
|
||||
// InvitationService manages invitation persistence
|
||||
type InvitationService struct {
|
||||
mu sync.RWMutex
|
||||
database *userpb.InvitationDatabase
|
||||
dataPath string
|
||||
lastModTime time.Time
|
||||
}
|
||||
|
||||
// NewInvitationService creates a new invitation service with persistence
|
||||
func NewInvitationService(dataDir string) (*InvitationService, error) {
|
||||
// Ensure data directory exists
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
dataPath := filepath.Join(dataDir, "invitations.pb")
|
||||
|
||||
is := &InvitationService{
|
||||
dataPath: dataPath,
|
||||
database: &userpb.InvitationDatabase{
|
||||
CodeIndex: make(map[string]int32),
|
||||
},
|
||||
}
|
||||
|
||||
// Try to load existing data
|
||||
if err := is.loadFrom(dataPath); err == nil {
|
||||
if info, err := os.Stat(dataPath); err == nil {
|
||||
is.lastModTime = info.ModTime()
|
||||
}
|
||||
log.Printf("Loaded %d invitations from %s", len(is.database.Invitations), dataPath)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to load invitation database: %w", err)
|
||||
} else {
|
||||
log.Printf("Starting with empty invitation database at %s", dataPath)
|
||||
}
|
||||
|
||||
return is, nil
|
||||
}
|
||||
|
||||
// loadFrom reads the database from a specific path
|
||||
func (is *InvitationService) loadFrom(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db := &userpb.InvitationDatabase{}
|
||||
if err := proto.Unmarshal(data, db); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal database: %w", err)
|
||||
}
|
||||
|
||||
// Ensure map is initialized
|
||||
if db.CodeIndex == nil {
|
||||
db.CodeIndex = make(map[string]int32)
|
||||
}
|
||||
|
||||
is.database = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// save writes the database to disk using atomic rename
|
||||
func (is *InvitationService) save() error {
|
||||
data, err := proto.Marshal(is.database)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal database: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(is.dataPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := is.dataPath + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, is.dataPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
|
||||
if info, err := os.Stat(is.dataPath); err == nil {
|
||||
is.lastModTime = info.ModTime()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateCode generates a cryptographically secure invitation code
|
||||
func generateCode() (string, error) {
|
||||
b := make([]byte, InvitationCodeLength)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
// Use URL-safe base64 encoding without padding
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CreateInvitation creates a new invitation and returns it
|
||||
func (is *InvitationService) CreateInvitation(email, createdByUserID string, expiresInDays int) (*userpb.Invitation, error) {
|
||||
is.mu.Lock()
|
||||
defer is.mu.Unlock()
|
||||
|
||||
// Validate email (basic check)
|
||||
email = strings.TrimSpace(email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
return nil, fmt.Errorf("invalid email address")
|
||||
}
|
||||
|
||||
// Use default expiration if not specified
|
||||
if expiresInDays <= 0 {
|
||||
expiresInDays = DefaultExpirationDays
|
||||
}
|
||||
|
||||
// Generate unique code
|
||||
code, err := generateCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
expiresAt := now.AddDate(0, 0, expiresInDays)
|
||||
|
||||
invitation := &userpb.Invitation{
|
||||
InvitationCode: code,
|
||||
Email: strings.ToLower(email),
|
||||
CreatedByUserId: createdByUserID,
|
||||
CreatedAt: timestamppb.New(now),
|
||||
ExpiresAt: timestamppb.New(expiresAt),
|
||||
Status: userpb.InvitationStatus_INVITATION_STATUS_PENDING,
|
||||
RedeemedByUserId: "",
|
||||
RedeemedAt: nil,
|
||||
}
|
||||
|
||||
// Add to database
|
||||
idx := int32(len(is.database.Invitations))
|
||||
is.database.Invitations = append(is.database.Invitations, invitation)
|
||||
is.database.CodeIndex[code] = idx
|
||||
|
||||
if err := is.save(); err != nil {
|
||||
return nil, fmt.Errorf("failed to save invitation: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Invitation] Created invitation for %s by user %s (expires %s)", email, createdByUserID, expiresAt.Format(time.RFC3339))
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// FindByCode looks up an invitation by its code
|
||||
func (is *InvitationService) FindByCode(code string) *userpb.Invitation {
|
||||
is.mu.RLock()
|
||||
defer is.mu.RUnlock()
|
||||
|
||||
if idx, ok := is.database.CodeIndex[code]; ok {
|
||||
if int(idx) < len(is.database.Invitations) {
|
||||
return is.database.Invitations[idx]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAndRedeem validates an invitation code and marks it as redeemed
|
||||
// Returns the invitation if valid, or an error describing why it's invalid
|
||||
func (is *InvitationService) ValidateAndRedeem(code, redeemedByUserID string) (*userpb.Invitation, error) {
|
||||
is.mu.Lock()
|
||||
defer is.mu.Unlock()
|
||||
|
||||
idx, ok := is.database.CodeIndex[code]
|
||||
if !ok || int(idx) >= len(is.database.Invitations) {
|
||||
return nil, fmt.Errorf("invalid invitation code")
|
||||
}
|
||||
|
||||
invitation := is.database.Invitations[idx]
|
||||
|
||||
// Check status
|
||||
switch invitation.Status {
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
|
||||
return nil, fmt.Errorf("invitation has already been used")
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
|
||||
return nil, fmt.Errorf("invitation has expired")
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
|
||||
return nil, fmt.Errorf("invitation has been revoked")
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
|
||||
// Check if expired by date
|
||||
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
|
||||
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
|
||||
is.database.Invitations[idx] = invitation
|
||||
is.save() // Best effort save
|
||||
return nil, fmt.Errorf("invitation has expired")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("invitation is not valid")
|
||||
}
|
||||
|
||||
// Mark as redeemed
|
||||
now := timestamppb.Now()
|
||||
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_REDEEMED
|
||||
invitation.RedeemedByUserId = redeemedByUserID
|
||||
invitation.RedeemedAt = now
|
||||
is.database.Invitations[idx] = invitation
|
||||
|
||||
if err := is.save(); err != nil {
|
||||
return nil, fmt.Errorf("failed to save redemption: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Invitation] Code %s redeemed by user %s", code[:8]+"...", redeemedByUserID)
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
// IsValidCode checks if a code is valid (pending and not expired)
|
||||
// This is a non-mutating check for pre-validation
|
||||
func (is *InvitationService) IsValidCode(code string) bool {
|
||||
is.mu.RLock()
|
||||
defer is.mu.RUnlock()
|
||||
|
||||
idx, ok := is.database.CodeIndex[code]
|
||||
if !ok || int(idx) >= len(is.database.Invitations) {
|
||||
return false
|
||||
}
|
||||
|
||||
invitation := is.database.Invitations[idx]
|
||||
|
||||
if invitation.Status != userpb.InvitationStatus_INVITATION_STATUS_PENDING {
|
||||
return false
|
||||
}
|
||||
|
||||
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Revoke revokes an invitation (prevents it from being used)
|
||||
func (is *InvitationService) Revoke(code string) error {
|
||||
is.mu.Lock()
|
||||
defer is.mu.Unlock()
|
||||
|
||||
idx, ok := is.database.CodeIndex[code]
|
||||
if !ok || int(idx) >= len(is.database.Invitations) {
|
||||
return fmt.Errorf("invitation not found")
|
||||
}
|
||||
|
||||
invitation := is.database.Invitations[idx]
|
||||
|
||||
if invitation.Status == userpb.InvitationStatus_INVITATION_STATUS_REDEEMED {
|
||||
return fmt.Errorf("cannot revoke an already-redeemed invitation")
|
||||
}
|
||||
|
||||
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_REVOKED
|
||||
is.database.Invitations[idx] = invitation
|
||||
|
||||
if err := is.save(); err != nil {
|
||||
return fmt.Errorf("failed to save revocation: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Invitation] Code %s revoked", code[:8]+"...")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListInvitations returns invitations with optional filtering and pagination
|
||||
func (is *InvitationService) ListInvitations(statusFilter userpb.InvitationStatus, emailFilter string, offset, limit int) ([]*userpb.Invitation, int) {
|
||||
is.mu.RLock()
|
||||
defer is.mu.RUnlock()
|
||||
|
||||
// Default and cap limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Update expired status for pending invitations
|
||||
now := time.Now()
|
||||
for i, inv := range is.database.Invitations {
|
||||
if inv.Status == userpb.InvitationStatus_INVITATION_STATUS_PENDING {
|
||||
if inv.ExpiresAt != nil && inv.ExpiresAt.AsTime().Before(now) {
|
||||
is.database.Invitations[i].Status = userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter invitations
|
||||
emailFilterLower := strings.ToLower(emailFilter)
|
||||
var filtered []*userpb.Invitation
|
||||
for _, inv := range is.database.Invitations {
|
||||
// Status filter (0 = all)
|
||||
if statusFilter != userpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED && inv.Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
// Email filter
|
||||
if emailFilter != "" && !strings.Contains(inv.Email, emailFilterLower) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, inv)
|
||||
}
|
||||
|
||||
totalCount := len(filtered)
|
||||
|
||||
// Apply pagination
|
||||
if offset >= len(filtered) {
|
||||
return nil, totalCount
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
return filtered[offset:end], totalCount
|
||||
}
|
||||
|
||||
// GetStats returns invitation statistics
|
||||
func (is *InvitationService) GetStats() (pending, redeemed, expired, revoked int) {
|
||||
is.mu.RLock()
|
||||
defer is.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
for _, inv := range is.database.Invitations {
|
||||
switch inv.Status {
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
|
||||
// Check if actually expired
|
||||
if inv.ExpiresAt != nil && inv.ExpiresAt.AsTime().Before(now) {
|
||||
expired++
|
||||
} else {
|
||||
pending++
|
||||
}
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
|
||||
redeemed++
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
|
||||
expired++
|
||||
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
|
||||
revoked++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -101,31 +101,14 @@ func main() {
|
||||
log.Printf("Legacy migration path configured: %s", actualLegacyDataDir)
|
||||
}
|
||||
|
||||
// Create invitation service for invitation-based registration
|
||||
invitationService, err := NewInvitationService(actualDataDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create invitation service: %v", err)
|
||||
}
|
||||
log.Printf("Invitation service initialized")
|
||||
|
||||
// Log invitation requirement status
|
||||
if os.Getenv("REQUIRE_INVITATION_CODE") == "true" || os.Getenv("REQUIRE_INVITATION_CODE") == "1" {
|
||||
log.Printf("Invitation codes REQUIRED for new accounts (REQUIRE_INVITATION_CODE=true)")
|
||||
} else {
|
||||
log.Printf("Invitation codes NOT required for new accounts (set REQUIRE_INVITATION_CODE=true to enable)")
|
||||
}
|
||||
|
||||
// Create email service for sending invitation emails
|
||||
emailService := NewEmailService()
|
||||
|
||||
// Create OAuth service
|
||||
oauthSvc := NewOAuthService(getOAuthConfigs())
|
||||
|
||||
// Create auth service handler
|
||||
authHandler := NewAuthHandler(oauthSvc, userService, invitationService)
|
||||
authHandler := NewAuthHandler(oauthSvc, userService)
|
||||
|
||||
// Create admin service handler
|
||||
adminHandler := NewAdminHandler(userService, invitationService, emailService)
|
||||
adminHandler := NewAdminHandler(userService)
|
||||
|
||||
// Start gRPC server
|
||||
grpcLis, err := net.Listen("tcp", fmt.Sprintf(":%d", actualGRPCPort))
|
||||
@@ -144,17 +127,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Start HTTP server for OAuth callbacks and invitation pages
|
||||
// Start HTTP server for OAuth callbacks
|
||||
http.HandleFunc("/oauth/callback", oauthSvc.HandleCallback)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "OK")
|
||||
})
|
||||
|
||||
// Register invitation landing page routes
|
||||
inviteHandler := NewInvitationHTTPHandler(invitationService)
|
||||
inviteHandler.RegisterRoutes()
|
||||
|
||||
log.Printf("HTTP server listening on port %d", actualHTTPPort)
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", actualHTTPPort), nil); err != nil {
|
||||
log.Fatalf("HTTP server failed: %v", err)
|
||||
|
||||
@@ -27,10 +27,9 @@ type OAuthProviderConfig struct {
|
||||
|
||||
// OAuthState represents a pending OAuth session
|
||||
type OAuthState struct {
|
||||
Provider string
|
||||
CreatedAt time.Time
|
||||
ReturnURL string // Optional: URL to redirect to after callback (for web clients)
|
||||
InvitationCode string // Optional: Invitation code for new user registration
|
||||
Provider string
|
||||
CreatedAt time.Time
|
||||
ReturnURL string // Optional: URL to redirect to after callback (for web clients)
|
||||
}
|
||||
|
||||
// ProviderUserInfo holds user info from an OAuth provider
|
||||
@@ -43,11 +42,10 @@ type ProviderUserInfo struct {
|
||||
|
||||
// OAuthResult represents the result of an OAuth flow
|
||||
type OAuthResult struct {
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
InvitationCode string // Invitation code from the original request
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
}
|
||||
|
||||
// OAuthService manages OAuth state and handles callbacks
|
||||
@@ -94,8 +92,7 @@ func getCallbackURL() string {
|
||||
|
||||
// GetAuthURL generates an OAuth authorization URL
|
||||
// returnURL is optional - if set, the callback will redirect there instead of showing "close window"
|
||||
// invitationCode is optional - if set, it will be stored with the OAuth state for new user validation
|
||||
func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationCode string) (authURL string, state string, err error) {
|
||||
func (s *OAuthService) GetAuthURL(provider string, returnURL string) (authURL string, state string, err error) {
|
||||
config, ok := s.configs[provider]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unsupported provider: %s", provider)
|
||||
@@ -103,13 +100,12 @@ func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationC
|
||||
|
||||
state = uuid.New().String()
|
||||
s.pendingOAuth.Store(state, OAuthState{
|
||||
Provider: provider,
|
||||
CreatedAt: time.Now(),
|
||||
ReturnURL: returnURL,
|
||||
InvitationCode: invitationCode,
|
||||
Provider: provider,
|
||||
CreatedAt: time.Now(),
|
||||
ReturnURL: returnURL,
|
||||
})
|
||||
|
||||
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s hasInvitationCode=%v", state, provider, returnURL, invitationCode != "")
|
||||
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s", state, provider, returnURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("client_id", config.ClientID)
|
||||
@@ -210,10 +206,9 @@ func (s *OAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// Mark as completed
|
||||
s.pendingOAuth.Delete(state)
|
||||
s.completedOAuth.Store(state, OAuthResult{
|
||||
Success: true,
|
||||
UserInfo: userInfo,
|
||||
Provider: oauthState.Provider,
|
||||
InvitationCode: oauthState.InvitationCode,
|
||||
Success: true,
|
||||
UserInfo: userInfo,
|
||||
Provider: oauthState.Provider,
|
||||
})
|
||||
|
||||
log.Printf("OAuth callback: SUCCESS state=%s user=%s returnURL=%s", state, userInfo.Username, oauthState.ReturnURL)
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestGetAuthURL(t *testing.T) {
|
||||
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
url, state, err := svc.GetAuthURL("discord", "", "")
|
||||
url, state, err := svc.GetAuthURL("discord", "")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthURL failed: %v", err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestGetAuthURL_UnknownProvider(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
_, _, err := svc.GetAuthURL("unknown", "", "")
|
||||
_, _, err := svc.GetAuthURL("unknown", "")
|
||||
if err == nil {
|
||||
t.Error("Expected error for unknown provider")
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestGetAuthURL_EmptyClientID(t *testing.T) {
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
url, state, err := svc.GetAuthURL("discord", "", "")
|
||||
url, state, err := svc.GetAuthURL("discord", "")
|
||||
// No error expected - the provider exists, it just has empty client ID
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
@@ -105,7 +105,7 @@ func TestIsPending(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create a pending state
|
||||
_, state, _ := svc.GetAuthURL("discord", "", "")
|
||||
_, state, _ := svc.GetAuthURL("discord", "")
|
||||
|
||||
if !svc.IsPending(state) {
|
||||
t.Error("State should be pending after GetAuthURL")
|
||||
@@ -122,7 +122,7 @@ func TestCheckStatus_Pending(t *testing.T) {
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
_, state, _ := svc.GetAuthURL("discord", "", "")
|
||||
_, state, _ := svc.GetAuthURL("discord", "")
|
||||
|
||||
result := svc.CheckStatus(state)
|
||||
if result.Success {
|
||||
@@ -157,7 +157,7 @@ func TestCheckStatus_Completed(t *testing.T) {
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
// Create a pending state
|
||||
_, state, _ := svc.GetAuthURL("discord", "", "")
|
||||
_, state, _ := svc.GetAuthURL("discord", "")
|
||||
|
||||
// Simulate completion by directly storing in completedOAuth
|
||||
testResult := OAuthResult{
|
||||
@@ -194,7 +194,7 @@ func TestCheckStatus_Error(t *testing.T) {
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
// Create a pending state
|
||||
_, state, _ := svc.GetAuthURL("discord", "", "")
|
||||
_, state, _ := svc.GetAuthURL("discord", "")
|
||||
|
||||
// Simulate error by storing error result
|
||||
errorResult := OAuthResult{
|
||||
@@ -223,7 +223,7 @@ func TestPendingOAuthExpiry(t *testing.T) {
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
_, state, _ := svc.GetAuthURL("discord", "", "")
|
||||
_, state, _ := svc.GetAuthURL("discord", "")
|
||||
|
||||
// Verify the pending entry has a creation time
|
||||
value, ok := svc.pendingOAuth.Load(state)
|
||||
@@ -296,9 +296,9 @@ func TestMultiplePendingStates(t *testing.T) {
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
// Create multiple pending states
|
||||
_, state1, _ := svc.GetAuthURL("discord", "", "")
|
||||
_, state2, _ := svc.GetAuthURL("discord", "", "")
|
||||
_, state3, _ := svc.GetAuthURL("discord", "", "")
|
||||
_, state1, _ := svc.GetAuthURL("discord", "")
|
||||
_, state2, _ := svc.GetAuthURL("discord", "")
|
||||
_, state3, _ := svc.GetAuthURL("discord", "")
|
||||
|
||||
// All should be unique
|
||||
if state1 == state2 || state2 == state3 || state1 == state3 {
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
// Package main provides email sending via Fastmail JMAP API for the auth service.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
|
||||
)
|
||||
|
||||
const (
|
||||
fastmailSessionURL = "https://api.fastmail.com/jmap/session"
|
||||
fastmailAPIURL = "https://api.fastmail.com/jmap/api/"
|
||||
)
|
||||
|
||||
// EmailService handles sending emails via Fastmail JMAP API
|
||||
type EmailService struct {
|
||||
apiToken string
|
||||
fromEmail string
|
||||
fromName string
|
||||
installerURL string
|
||||
serverBaseURL string
|
||||
httpClient *http.Client
|
||||
enabled bool
|
||||
|
||||
// JMAP session info (populated on first send)
|
||||
accountID string
|
||||
identityID string
|
||||
draftsID string
|
||||
}
|
||||
|
||||
// NewEmailService creates a new email service
|
||||
// Returns a disabled service if FASTMAIL_API_TOKEN is not set
|
||||
func NewEmailService() *EmailService {
|
||||
apiToken := os.Getenv("FASTMAIL_API_TOKEN")
|
||||
fromEmail := os.Getenv("FASTMAIL_FROM_EMAIL")
|
||||
fromName := os.Getenv("FASTMAIL_FROM_NAME")
|
||||
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
|
||||
serverBaseURL := os.Getenv("SERVER_BASE_URL")
|
||||
|
||||
if fromEmail == "" {
|
||||
fromEmail = "noreply@eagle0.net"
|
||||
}
|
||||
if fromName == "" {
|
||||
fromName = "Eagle0 Game"
|
||||
}
|
||||
if installerURL == "" {
|
||||
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
|
||||
}
|
||||
if serverBaseURL == "" {
|
||||
serverBaseURL = "https://prod.eagle0.net"
|
||||
}
|
||||
|
||||
enabled := apiToken != ""
|
||||
if !enabled {
|
||||
log.Printf("WARNING: FASTMAIL_API_TOKEN not set - email sending disabled")
|
||||
} else {
|
||||
log.Printf("Email service initialized (JMAP: api.fastmail.com, from: %s <%s>)", fromName, fromEmail)
|
||||
}
|
||||
|
||||
return &EmailService{
|
||||
apiToken: apiToken,
|
||||
fromEmail: fromEmail,
|
||||
fromName: fromName,
|
||||
installerURL: installerURL,
|
||||
serverBaseURL: serverBaseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns true if email sending is configured
|
||||
func (e *EmailService) IsEnabled() bool {
|
||||
return e.enabled
|
||||
}
|
||||
|
||||
// SendInvitation sends an invitation email
|
||||
func (e *EmailService) SendInvitation(invitation *userpb.Invitation) error {
|
||||
if !e.enabled {
|
||||
log.Printf("[Email] Would send invitation to %s (email disabled)", invitation.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize JMAP session if needed
|
||||
if e.accountID == "" {
|
||||
if err := e.initSession(); err != nil {
|
||||
return fmt.Errorf("failed to initialize JMAP session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build invite page URL
|
||||
invitePageURL := fmt.Sprintf("%s/invite/%s", e.serverBaseURL, invitation.InvitationCode)
|
||||
|
||||
// Build email content
|
||||
subject := "You've been invited to play Eagle0!"
|
||||
htmlContent, err := e.buildInvitationHTML(invitation, invitePageURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build email content: %w", err)
|
||||
}
|
||||
|
||||
textContent := e.buildInvitationText(invitation, invitePageURL)
|
||||
|
||||
// Send via JMAP
|
||||
if err := e.sendEmail(invitation.Email, subject, htmlContent, textContent); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Email] Sent invitation email to %s", invitation.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
// initSession fetches JMAP session info (account ID, identity ID, drafts mailbox ID)
|
||||
func (e *EmailService) initSession() error {
|
||||
// Get session
|
||||
req, err := http.NewRequest("GET", fastmailSessionURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiToken)
|
||||
|
||||
resp, err := e.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("session request failed: %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var session struct {
|
||||
PrimaryAccounts map[string]string `json:"primaryAccounts"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
||||
return fmt.Errorf("failed to decode session: %w", err)
|
||||
}
|
||||
|
||||
e.accountID = session.PrimaryAccounts["urn:ietf:params:jmap:mail"]
|
||||
if e.accountID == "" {
|
||||
return fmt.Errorf("no mail account found in session")
|
||||
}
|
||||
log.Printf("[Email] JMAP account ID: %s", e.accountID)
|
||||
|
||||
// Get identity ID and drafts mailbox ID
|
||||
if err := e.fetchIdentityAndDrafts(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchIdentityAndDrafts gets the identity ID and drafts mailbox ID
|
||||
func (e *EmailService) fetchIdentityAndDrafts() error {
|
||||
payload := map[string]interface{}{
|
||||
"using": []string{
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:submission",
|
||||
},
|
||||
"methodCalls": []interface{}{
|
||||
[]interface{}{"Identity/get", map[string]interface{}{"accountId": e.accountID}, "0"},
|
||||
[]interface{}{"Mailbox/query", map[string]interface{}{
|
||||
"accountId": e.accountID,
|
||||
"filter": map[string]string{"role": "drafts"},
|
||||
}, "1"},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", fastmailAPIURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := e.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JMAP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("JMAP request failed: %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
MethodResponses [][]interface{} `json:"methodResponses"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("failed to decode JMAP response: %w", err)
|
||||
}
|
||||
|
||||
// Extract identity ID from first response
|
||||
if len(result.MethodResponses) > 0 {
|
||||
if data, ok := result.MethodResponses[0][1].(map[string]interface{}); ok {
|
||||
if list, ok := data["list"].([]interface{}); ok && len(list) > 0 {
|
||||
if identity, ok := list[0].(map[string]interface{}); ok {
|
||||
if id, ok := identity["id"].(string); ok {
|
||||
e.identityID = id
|
||||
}
|
||||
// Use the identity's email if we don't have one configured
|
||||
if email, ok := identity["email"].(string); ok && e.fromEmail == "noreply@eagle0.net" {
|
||||
e.fromEmail = email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract drafts mailbox ID from second response
|
||||
if len(result.MethodResponses) > 1 {
|
||||
if data, ok := result.MethodResponses[1][1].(map[string]interface{}); ok {
|
||||
if ids, ok := data["ids"].([]interface{}); ok && len(ids) > 0 {
|
||||
if id, ok := ids[0].(string); ok {
|
||||
e.draftsID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if e.identityID == "" {
|
||||
return fmt.Errorf("failed to get identity ID")
|
||||
}
|
||||
if e.draftsID == "" {
|
||||
return fmt.Errorf("failed to get drafts mailbox ID")
|
||||
}
|
||||
|
||||
log.Printf("[Email] JMAP identity: %s, drafts: %s", e.identityID, e.draftsID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailService) buildInvitationHTML(invitation *userpb.Invitation, invitePageURL string) (string, error) {
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Eagle0 Invitation</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { text-align: center; padding: 20px 0; }
|
||||
.logo { font-size: 32px; font-weight: bold; color: #1a5f7a; }
|
||||
.content { background: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0; }
|
||||
.button { display: inline-block; background: #1a5f7a; color: white !important; text-decoration: none; padding: 14px 28px; border-radius: 6px; font-weight: bold; margin: 20px 0; }
|
||||
.button:hover { background: #154d62; }
|
||||
.footer { text-align: center; color: #6c757d; font-size: 12px; margin-top: 30px; }
|
||||
.expires { color: #856404; background: #fff3cd; padding: 10px; border-radius: 4px; margin-top: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="logo">Eagle0</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>You've been invited!</h2>
|
||||
<p>Someone has invited you to join Eagle0, a strategic turn-based game with tactical hex-based combat.</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.InvitePageURL}}" class="button">Accept Invitation</a>
|
||||
</p>
|
||||
|
||||
<p style="text-align: center; color: #666;">Click the button above to download and install Eagle0.</p>
|
||||
|
||||
<div class="expires">
|
||||
This invitation expires on {{.ExpiresAt}}.
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 20px; font-size: 12px; color: #999;">
|
||||
If the button doesn't work, copy this link into your browser:<br>
|
||||
<a href="{{.InvitePageURL}}" style="color: #1a5f7a;">{{.InvitePageURL}}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This is an automated message. If you didn't expect this invitation, you can safely ignore it.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
tmpl, err := template.New("invitation").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var expiresAt string
|
||||
if invitation.ExpiresAt != nil {
|
||||
expiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
|
||||
} else {
|
||||
expiresAt = "N/A"
|
||||
}
|
||||
|
||||
data := map[string]string{
|
||||
"InvitePageURL": invitePageURL,
|
||||
"ExpiresAt": expiresAt,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (e *EmailService) buildInvitationText(invitation *userpb.Invitation, invitePageURL string) string {
|
||||
var expiresAt string
|
||||
if invitation.ExpiresAt != nil {
|
||||
expiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
|
||||
} else {
|
||||
expiresAt = "N/A"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`You've been invited to play Eagle0!
|
||||
|
||||
Someone has invited you to join Eagle0, a strategic turn-based game with tactical hex-based combat.
|
||||
|
||||
Click here to accept your invitation and download the game:
|
||||
%s
|
||||
|
||||
This invitation expires on %s.
|
||||
|
||||
---
|
||||
This is an automated message. If you didn't expect this invitation, you can safely ignore it.
|
||||
`, invitePageURL, expiresAt)
|
||||
}
|
||||
|
||||
// sendEmail sends an email via Fastmail JMAP API
|
||||
func (e *EmailService) sendEmail(to, subject, htmlContent, textContent string) error {
|
||||
// Create email and submit in one request
|
||||
payload := map[string]interface{}{
|
||||
"using": []string{
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:submission",
|
||||
},
|
||||
"methodCalls": []interface{}{
|
||||
[]interface{}{"Email/set", map[string]interface{}{
|
||||
"accountId": e.accountID,
|
||||
"create": map[string]interface{}{
|
||||
"draft": map[string]interface{}{
|
||||
"from": []map[string]string{
|
||||
{"name": e.fromName, "email": e.fromEmail},
|
||||
},
|
||||
"to": []map[string]string{
|
||||
{"email": to},
|
||||
},
|
||||
"subject": subject,
|
||||
"mailboxIds": map[string]bool{e.draftsID: true},
|
||||
"keywords": map[string]bool{"$draft": true},
|
||||
"bodyValues": map[string]interface{}{
|
||||
"text": map[string]interface{}{
|
||||
"charset": "utf-8",
|
||||
"value": textContent,
|
||||
},
|
||||
"html": map[string]interface{}{
|
||||
"charset": "utf-8",
|
||||
"value": htmlContent,
|
||||
},
|
||||
},
|
||||
"textBody": []map[string]string{
|
||||
{"partId": "text", "type": "text/plain"},
|
||||
},
|
||||
"htmlBody": []map[string]string{
|
||||
{"partId": "html", "type": "text/html"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, "0"},
|
||||
[]interface{}{"EmailSubmission/set", map[string]interface{}{
|
||||
"accountId": e.accountID,
|
||||
"onSuccessDestroyEmail": []string{"#sendIt"},
|
||||
"create": map[string]interface{}{
|
||||
"sendIt": map[string]interface{}{
|
||||
"emailId": "#draft",
|
||||
"identityId": e.identityID,
|
||||
},
|
||||
},
|
||||
}, "1"},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", fastmailAPIURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := e.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("JMAP API error: %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Check for errors in response
|
||||
var result struct {
|
||||
MethodResponses [][]interface{} `json:"methodResponses"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// Check Email/set response for errors
|
||||
if len(result.MethodResponses) > 0 {
|
||||
if data, ok := result.MethodResponses[0][1].(map[string]interface{}); ok {
|
||||
if notCreated, ok := data["notCreated"].(map[string]interface{}); ok {
|
||||
if draft, ok := notCreated["draft"].(map[string]interface{}); ok {
|
||||
if desc, ok := draft["description"].(string); ok {
|
||||
return fmt.Errorf("failed to create email: %s", desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check EmailSubmission/set response for errors
|
||||
if len(result.MethodResponses) > 1 {
|
||||
if data, ok := result.MethodResponses[1][1].(map[string]interface{}); ok {
|
||||
if notCreated, ok := data["notCreated"].(map[string]interface{}); ok {
|
||||
if sendIt, ok := notCreated["sendIt"].(map[string]interface{}); ok {
|
||||
if desc, ok := sendIt["description"].(string); ok {
|
||||
return fmt.Errorf("failed to send email: %s", desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -228,22 +228,6 @@ func (us *UserService) FindByUserID(userID string) *userpb.User {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindByOAuthIdentity finds a user by their OAuth provider and provider user ID
|
||||
func (us *UserService) FindByOAuthIdentity(provider, providerUserID string) *userpb.User {
|
||||
us.mu.RLock()
|
||||
defer us.mu.RUnlock()
|
||||
|
||||
oauthKey := fmt.Sprintf("%s:%s", provider, providerUserID)
|
||||
if userID, ok := us.database.OauthIndex[oauthKey]; ok {
|
||||
for _, user := range us.database.Users {
|
||||
if user.UserId == userID {
|
||||
return user
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindByDisplayName finds a user by display name (case-insensitive)
|
||||
func (us *UserService) FindByDisplayName(displayName string) *userpb.User {
|
||||
us.mu.RLock()
|
||||
|
||||
@@ -55,8 +55,8 @@ func main() {
|
||||
log.Fatalf("Failed to create AWS client: %v", err)
|
||||
}
|
||||
|
||||
// Upload installer to S3 with public ACL
|
||||
err = bucketBasics.UploadFilePublic(bucketName, installerKey, installerPath)
|
||||
// Upload installer to S3
|
||||
err = bucketBasics.UploadFile(bucketName, installerKey, installerPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to upload installer: %v", err)
|
||||
}
|
||||
@@ -73,8 +73,8 @@ func main() {
|
||||
log.Fatalf("Failed to create version file: %v", err)
|
||||
}
|
||||
|
||||
// Upload version file to S3 with public ACL
|
||||
err = bucketBasics.UploadFilePublic(bucketName, versionKey, tempVersionFile)
|
||||
// Upload version file to S3
|
||||
err = bucketBasics.UploadFile(bucketName, versionKey, tempVersionFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to upload version file: %v", err)
|
||||
}
|
||||
|
||||
@@ -112,8 +112,8 @@ func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) err
|
||||
// Build new manifest
|
||||
newManifest := mm.buildManifest(sections)
|
||||
|
||||
// Upload updated manifest with public ACL
|
||||
err = mm.bucketBasics.UploadBytesPublic(bucketName, manifestPath, []byte(newManifest))
|
||||
// Upload updated manifest
|
||||
err = mm.bucketBasics.UploadBytes(bucketName, manifestPath, []byte(newManifest))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload manifest: %v", err)
|
||||
}
|
||||
|
||||
+2
-2
@@ -118,7 +118,7 @@ func syncToS3(bb aws.BucketBasics, localDir string, s3Root string, localShas map
|
||||
}
|
||||
|
||||
if !objectMatches {
|
||||
err = bb.UploadFilePublic(bucketName, destinationPath, filePath)
|
||||
err = bb.UploadFile(bucketName, destinationPath, filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func handleDir(tempDir string, outputManifestPath string) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = bb.UploadBytesPublic(bucketName, manifestPath, []byte(localManifest))
|
||||
err = bb.UploadBytes(bucketName, manifestPath, []byte(localManifest))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -130,30 +130,16 @@ func (basics BucketBasics) FetchString(bucketName string, objectKey string) (str
|
||||
}
|
||||
|
||||
func (basics BucketBasics) UploadFile(bucketName string, objectKey string, fileName string) error {
|
||||
return basics.UploadFileWithACL(bucketName, objectKey, fileName, "")
|
||||
}
|
||||
|
||||
func (basics BucketBasics) UploadFilePublic(bucketName string, objectKey string, fileName string) error {
|
||||
return basics.UploadFileWithACL(bucketName, objectKey, fileName, "public-read")
|
||||
}
|
||||
|
||||
func (basics BucketBasics) UploadFileWithACL(bucketName string, objectKey string, fileName string, acl string) error {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
input := &s3.PutObjectInput{
|
||||
_, err = basics.S3Client.PutObject(basics.ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: file,
|
||||
}
|
||||
if acl != "" {
|
||||
input.ACL = types.ObjectCannedACL(acl)
|
||||
}
|
||||
|
||||
_, err = basics.S3Client.PutObject(basics.ctx, input)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,24 +153,11 @@ func (basics BucketBasics) UploadFileWithACL(bucketName string, objectKey string
|
||||
}
|
||||
|
||||
func (basics BucketBasics) UploadBytes(bucketName string, objectKey string, data []byte) error {
|
||||
return basics.UploadBytesWithACL(bucketName, objectKey, data, "")
|
||||
}
|
||||
|
||||
func (basics BucketBasics) UploadBytesPublic(bucketName string, objectKey string, data []byte) error {
|
||||
return basics.UploadBytesWithACL(bucketName, objectKey, data, "public-read")
|
||||
}
|
||||
|
||||
func (basics BucketBasics) UploadBytesWithACL(bucketName string, objectKey string, data []byte, acl string) error {
|
||||
input := &s3.PutObjectInput{
|
||||
_, err := basics.S3Client.PutObject(basics.ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
Key: aws.String(objectKey),
|
||||
Body: bytes.NewReader(data),
|
||||
}
|
||||
if acl != "" {
|
||||
input.ACL = types.ObjectCannedACL(acl)
|
||||
}
|
||||
|
||||
_, err := basics.S3Client.PutObject(basics.ctx, input)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "warmup_lib",
|
||||
srcs = ["main.go"],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/warmup",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto", # keep
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:common_go_proto",
|
||||
"@org_golang_google_grpc//:grpc",
|
||||
"@org_golang_google_grpc//credentials/insecure",
|
||||
"@org_golang_google_grpc//metadata",
|
||||
],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
name = "warmup",
|
||||
embed = [":warmup_lib"],
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
# Linux x86_64 target for Docker deployment
|
||||
go_binary(
|
||||
name = "warmup_linux_amd64",
|
||||
embed = [":warmup_lib"],
|
||||
goarch = "amd64",
|
||||
goos = "linux",
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -1,412 +0,0 @@
|
||||
// Package main implements a warmup tool for Eagle server.
|
||||
//
|
||||
// This tool warms up the JIT by:
|
||||
// 1. Entering the lobby to get available leaders
|
||||
// 2. Creating a new game with 2 players (1 human, 1 AI)
|
||||
// 3. Starting a bidirectional stream for that game
|
||||
// 4. Posting an Improve command when available
|
||||
// 5. Verifying that we get ActionResults including NEW_ROUND_ACTION
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// warmup --address=localhost:40032
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
eagle "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
|
||||
eaglecommon "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/common"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
var (
|
||||
address = flag.String("address", "localhost:40032", "Eagle server address (host:port)")
|
||||
timeout = flag.Duration("timeout", 60*time.Second, "Overall timeout for warmup")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
|
||||
if err := runWarmup(ctx); err != nil {
|
||||
log.Fatalf("Warmup failed: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Warmup completed successfully!")
|
||||
}
|
||||
|
||||
func runWarmup(ctx context.Context) error {
|
||||
log.Printf("Connecting to Eagle server at %s...", *address)
|
||||
|
||||
conn, err := grpc.NewClient(*address, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := eagle.NewEagleClient(conn)
|
||||
|
||||
// Add warmup user header to context
|
||||
md := metadata.Pairs("x-warmup-user", "warmup-tool")
|
||||
ctx = metadata.NewOutgoingContext(ctx, md)
|
||||
|
||||
// Start the bidirectional stream
|
||||
log.Println("Starting StreamUpdates...")
|
||||
stream, err := client.StreamUpdates(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start stream: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: Enter the lobby to get available leaders
|
||||
log.Println("Step 1: Entering lobby...")
|
||||
if err := stream.Send(&eagle.UpdateStreamRequest{
|
||||
RequestDetails: &eagle.UpdateStreamRequest_EnterLobbyRequest{
|
||||
EnterLobbyRequest: &eagle.EnterLobbyRequest{},
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to send lobby request: %w", err)
|
||||
}
|
||||
|
||||
lobbyResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
|
||||
return resp.GetLobbyResponse() != nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get lobby response: %w", err)
|
||||
}
|
||||
|
||||
lobby := lobbyResp.GetLobbyResponse()
|
||||
newGameOptions := lobby.GetNewGameOptions()
|
||||
if newGameOptions == nil || len(newGameOptions.GetAvailableLeaders()) == 0 {
|
||||
return fmt.Errorf("no available leaders in lobby response")
|
||||
}
|
||||
|
||||
leader := newGameOptions.GetAvailableLeaders()[0]
|
||||
log.Printf(" Found leader: %s", leader.GetNameTextId())
|
||||
|
||||
// Step 2: Create a new game
|
||||
log.Println("Step 2: Creating game...")
|
||||
if err := stream.Send(&eagle.UpdateStreamRequest{
|
||||
RequestDetails: &eagle.UpdateStreamRequest_CreateGameRequest{
|
||||
CreateGameRequest: &eagle.CreateGameRequest{
|
||||
DesiredLeaderTextId: leader.GetNameTextId(),
|
||||
TotalPlayerCount: 2,
|
||||
HumanPlayerCount: 1,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to send create game request: %w", err)
|
||||
}
|
||||
|
||||
createResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
|
||||
return resp.GetCreateGameResponse() != nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get create game response: %w", err)
|
||||
}
|
||||
|
||||
gameResp := createResp.GetCreateGameResponse()
|
||||
if gameResp.GetResult() != eagle.JoinGameResult_SUCCESS_JOIN_GAME_RESULT {
|
||||
return fmt.Errorf("failed to create game: %v", gameResp.GetResult())
|
||||
}
|
||||
|
||||
gameID := gameResp.GetGameId()
|
||||
log.Printf(" Created game: %d", gameID)
|
||||
|
||||
// Step 3: Subscribe to the game stream
|
||||
log.Println("Step 3: Subscribing to game...")
|
||||
if err := stream.Send(&eagle.UpdateStreamRequest{
|
||||
RequestDetails: &eagle.UpdateStreamRequest_StreamGameRequest_{
|
||||
StreamGameRequest: &eagle.UpdateStreamRequest_StreamGameRequest{
|
||||
GameId: gameID,
|
||||
UnfilteredResultCount: 0,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to send stream game request: %w", err)
|
||||
}
|
||||
|
||||
// Wait for subscription ack
|
||||
_, err = waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
|
||||
return resp.GetSubscriptionAck() != nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get subscription ack: %w", err)
|
||||
}
|
||||
log.Println(" Subscription acknowledged")
|
||||
|
||||
// Wait for initial game state with available commands
|
||||
gameUpdateResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
|
||||
gu := resp.GetGameUpdate()
|
||||
if gu == nil {
|
||||
return false
|
||||
}
|
||||
ar := gu.GetActionResultResponse()
|
||||
return ar != nil && ar.GetAvailableCommands() != nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get initial game state: %w", err)
|
||||
}
|
||||
|
||||
gameUpdate := gameUpdateResp.GetGameUpdate()
|
||||
actionResult := gameUpdate.GetActionResultResponse()
|
||||
availableCommands := actionResult.GetAvailableCommands()
|
||||
|
||||
// Get the token from available commands
|
||||
eagleToken := availableCommands.GetToken()
|
||||
|
||||
// Get commands by province from map
|
||||
commandsByProvince := availableCommands.GetCommandsByProvince()
|
||||
log.Printf(" Got game state with %d provinces having commands", len(commandsByProvince))
|
||||
|
||||
// Step 4: Find an Improve command and post it
|
||||
log.Println("Step 4: Looking for Improve command...")
|
||||
|
||||
var improveCmd *eagle.AvailableCommand
|
||||
var provinceID int32
|
||||
|
||||
for pid, provCmds := range commandsByProvince {
|
||||
for _, cmd := range provCmds.GetCommands() {
|
||||
if cmd.GetImproveCommand() != nil {
|
||||
improveCmd = cmd
|
||||
provinceID = pid
|
||||
break
|
||||
}
|
||||
}
|
||||
if improveCmd != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if improveCmd == nil {
|
||||
log.Println(" No Improve command available, looking for any command...")
|
||||
// Try to find any province with commands
|
||||
for pid, provCmds := range commandsByProvince {
|
||||
if len(provCmds.GetCommands()) > 0 {
|
||||
provinceID = pid
|
||||
log.Printf(" Found command group for province %d with %d commands", pid, len(provCmds.GetCommands()))
|
||||
break
|
||||
}
|
||||
}
|
||||
// We'll still try to post an Improve command even if not explicitly available
|
||||
} else {
|
||||
log.Printf(" Found Improve command for province %d", provinceID)
|
||||
}
|
||||
|
||||
// Post the Improve command
|
||||
log.Println(" Posting Improve command...")
|
||||
if err := stream.Send(&eagle.UpdateStreamRequest{
|
||||
RequestDetails: &eagle.UpdateStreamRequest_PostCommandRequest{
|
||||
PostCommandRequest: &eagle.PostCommandRequest{
|
||||
GameId: gameID,
|
||||
Command: &eagle.UniversalCommand{
|
||||
SealedValue: &eagle.UniversalCommand_EagleCommand{
|
||||
EagleCommand: &eagle.EagleCommand{
|
||||
ProvinceId: provinceID,
|
||||
EagleToken: eagleToken,
|
||||
Command: &eagle.SelectedCommand{
|
||||
SealedValue: &eagle.SelectedCommand_ImproveCommand{
|
||||
ImproveCommand: &eagle.ImproveSelectedCommand{
|
||||
ImprovementType: eaglecommon.ImprovementType_ECONOMY,
|
||||
ActingHeroId: 0, // Default acting hero
|
||||
LockType: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to send post command request: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Verify we get ActionResults
|
||||
log.Println("Step 5: Waiting for command response...")
|
||||
|
||||
// Wait for post command response
|
||||
postResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
|
||||
return resp.GetPostCommandResponse() != nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get post command response: %w", err)
|
||||
}
|
||||
|
||||
if postResp.GetPostCommandResponse().GetStatus() != eagle.PostCommandResponse_SUCCESS {
|
||||
log.Printf(" Command was rejected: %v", postResp.GetPostCommandResponse().GetStatus())
|
||||
// Continue anyway - the JIT is still warming up
|
||||
} else {
|
||||
log.Println(" Command accepted!")
|
||||
}
|
||||
|
||||
// Collect action results with a timeout
|
||||
log.Println(" Collecting action results...")
|
||||
resultCtx, resultCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer resultCancel()
|
||||
|
||||
foundNewRound := false
|
||||
resultCount := 0
|
||||
commandCount := 0
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-resultCtx.Done():
|
||||
log.Println(" Timeout waiting for more results (this is OK)")
|
||||
goto done
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := receiveWithTimeout(stream, 2*time.Second)
|
||||
if err != nil {
|
||||
if err == errTimeout {
|
||||
log.Println(" No more results received")
|
||||
goto done
|
||||
}
|
||||
return fmt.Errorf("error receiving: %w", err)
|
||||
}
|
||||
|
||||
gu := resp.GetGameUpdate()
|
||||
if gu == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ar := gu.GetActionResultResponse()
|
||||
if ar == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, arv := range ar.GetActionResultViews() {
|
||||
resultCount++
|
||||
if arv.GetType() == eaglecommon.ActionResultType_NEW_ROUND_ACTION {
|
||||
foundNewRound = true
|
||||
log.Printf(" Found NEW_ROUND_ACTION (date change)!")
|
||||
}
|
||||
}
|
||||
|
||||
if ar.GetAvailableCommands() != nil {
|
||||
commandCount = len(ar.GetAvailableCommands().GetCommandsByProvince())
|
||||
log.Printf(" Got %d action results, %d provinces with commands available", len(ar.GetActionResultViews()), commandCount)
|
||||
}
|
||||
}
|
||||
|
||||
done:
|
||||
// Verify results
|
||||
log.Println("Verification:")
|
||||
log.Printf(" Total action results received: %d", resultCount)
|
||||
log.Printf(" Found NEW_ROUND_ACTION (date change): %v", foundNewRound)
|
||||
log.Printf(" New commands available: %d provinces", commandCount)
|
||||
|
||||
// Step 6: Drop the game to clean up
|
||||
log.Println("Step 6: Dropping test game...")
|
||||
if err := stream.Send(&eagle.UpdateStreamRequest{
|
||||
RequestDetails: &eagle.UpdateStreamRequest_DropGameRequest{
|
||||
DropGameRequest: &eagle.DropGameRequest{
|
||||
GameId: gameID,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
log.Printf("Warning: failed to send drop game request: %v", err)
|
||||
} else {
|
||||
// Wait briefly for drop response
|
||||
_, err := waitForResponseWithTimeout(stream, func(resp *eagle.UpdateStreamResponse) bool {
|
||||
return resp.GetDropGameResponse() != nil
|
||||
}, 5*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to get drop game response: %v", err)
|
||||
} else {
|
||||
log.Println(" Game dropped successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// Report success based on what we achieved
|
||||
if resultCount == 0 {
|
||||
return fmt.Errorf("no action results received")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForResponse waits for a response matching the predicate
|
||||
func waitForResponse(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool) (*eagle.UpdateStreamResponse, error) {
|
||||
return waitForResponseWithTimeout(stream, matches, 30*time.Second)
|
||||
}
|
||||
|
||||
func waitForResponseWithTimeout(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
remaining := time.Until(deadline)
|
||||
resp, err := receiveWithTimeout(stream, remaining)
|
||||
if err != nil {
|
||||
if err == errTimeout {
|
||||
return nil, fmt.Errorf("timeout waiting for response")
|
||||
}
|
||||
log.Printf(" [DEBUG] Error receiving: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log what type of response we got
|
||||
switch resp.GetResponseDetails().(type) {
|
||||
case *eagle.UpdateStreamResponse_LobbyResponse:
|
||||
log.Printf(" [DEBUG] Got LobbyResponse")
|
||||
case *eagle.UpdateStreamResponse_CreateGameResponse:
|
||||
log.Printf(" [DEBUG] Got CreateGameResponse")
|
||||
case *eagle.UpdateStreamResponse_GameUpdate:
|
||||
log.Printf(" [DEBUG] Got GameUpdate")
|
||||
case *eagle.UpdateStreamResponse_SubscriptionAck:
|
||||
log.Printf(" [DEBUG] Got SubscriptionAck")
|
||||
case *eagle.UpdateStreamResponse_PostCommandResponse:
|
||||
log.Printf(" [DEBUG] Got PostCommandResponse")
|
||||
case *eagle.UpdateStreamResponse_DropGameResponse:
|
||||
log.Printf(" [DEBUG] Got DropGameResponse")
|
||||
default:
|
||||
log.Printf(" [DEBUG] Got unknown response type: %T", resp.GetResponseDetails())
|
||||
}
|
||||
|
||||
if matches(resp) {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("timeout waiting for response")
|
||||
}
|
||||
|
||||
var errTimeout = fmt.Errorf("receive timeout")
|
||||
|
||||
func receiveWithTimeout(stream eagle.Eagle_StreamUpdatesClient, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
|
||||
type result struct {
|
||||
resp *eagle.UpdateStreamResponse
|
||||
err error
|
||||
}
|
||||
|
||||
ch := make(chan result, 1)
|
||||
|
||||
go func() {
|
||||
resp, err := stream.Recv()
|
||||
ch <- result{resp, err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case r := <-ch:
|
||||
if r.err != nil {
|
||||
if r.err == io.EOF {
|
||||
return nil, fmt.Errorf("stream closed by server")
|
||||
}
|
||||
return nil, r.err
|
||||
}
|
||||
return r.resp, nil
|
||||
case <-time.After(timeout):
|
||||
return nil, errTimeout
|
||||
}
|
||||
}
|
||||
@@ -27,18 +27,6 @@ service Admin {
|
||||
|
||||
// Toggle admin status for a user
|
||||
rpc SetUserAdmin(SetUserAdminRequest) returns (SetUserAdminResponse) {}
|
||||
|
||||
// Create a new invitation and send email
|
||||
rpc CreateInvitation(CreateInvitationRequest) returns (CreateInvitationResponse) {}
|
||||
|
||||
// List all invitations with optional filtering
|
||||
rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse) {}
|
||||
|
||||
// Revoke an invitation (prevents it from being used)
|
||||
rpc RevokeInvitation(RevokeInvitationRequest) returns (RevokeInvitationResponse) {}
|
||||
|
||||
// Resend invitation email
|
||||
rpc ResendInvitation(ResendInvitationRequest) returns (ResendInvitationResponse) {}
|
||||
}
|
||||
|
||||
// Request to list users
|
||||
@@ -103,73 +91,3 @@ message SetUserAdminResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2;
|
||||
}
|
||||
|
||||
// ============== Invitation Management ==============
|
||||
|
||||
// Invitation status for admin view
|
||||
enum InvitationStatus {
|
||||
INVITATION_STATUS_UNSPECIFIED = 0;
|
||||
INVITATION_STATUS_PENDING = 1;
|
||||
INVITATION_STATUS_REDEEMED = 2;
|
||||
INVITATION_STATUS_EXPIRED = 3;
|
||||
INVITATION_STATUS_REVOKED = 4;
|
||||
}
|
||||
|
||||
// Invitation info for admin view
|
||||
message InvitationInfo {
|
||||
string invitation_code = 1;
|
||||
string email = 2;
|
||||
string created_by_user_id = 3;
|
||||
string created_by_display_name = 4; // Resolved for display
|
||||
google.protobuf.Timestamp created_at = 5;
|
||||
google.protobuf.Timestamp expires_at = 6;
|
||||
InvitationStatus status = 7;
|
||||
string redeemed_by_user_id = 8;
|
||||
string redeemed_by_display_name = 9; // Resolved for display
|
||||
google.protobuf.Timestamp redeemed_at = 10;
|
||||
}
|
||||
|
||||
// Create invitation request
|
||||
message CreateInvitationRequest {
|
||||
string email = 1; // Email to send invitation to
|
||||
int32 expires_in_days = 2; // Days until expiration (default 30 if 0)
|
||||
}
|
||||
|
||||
message CreateInvitationResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2;
|
||||
InvitationInfo invitation = 3;
|
||||
}
|
||||
|
||||
// List invitations request
|
||||
message ListInvitationsRequest {
|
||||
InvitationStatus status_filter = 1; // Optional: filter by status (0 = all)
|
||||
string email_filter = 2; // Optional: filter by email
|
||||
int32 offset = 3;
|
||||
int32 limit = 4; // Default 50, max 100
|
||||
}
|
||||
|
||||
message ListInvitationsResponse {
|
||||
repeated InvitationInfo invitations = 1;
|
||||
int32 total_count = 2;
|
||||
}
|
||||
|
||||
// Revoke invitation request
|
||||
message RevokeInvitationRequest {
|
||||
string invitation_code = 1;
|
||||
}
|
||||
|
||||
message RevokeInvitationResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2;
|
||||
}
|
||||
|
||||
// Resend invitation email request
|
||||
message ResendInvitationRequest {
|
||||
string invitation_code = 1;
|
||||
}
|
||||
|
||||
message ResendInvitationResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2;
|
||||
}
|
||||
|
||||
@@ -48,9 +48,6 @@ message GetOAuthUrlRequest {
|
||||
// If set, the auth service will redirect here after OAuth instead of
|
||||
// showing "close this window" message.
|
||||
string return_url = 2;
|
||||
// Optional: Invitation code for new account creation.
|
||||
// Required for new users; existing users are grandfathered and don't need this.
|
||||
string invitation_code = 3;
|
||||
}
|
||||
|
||||
message GetOAuthUrlResponse {
|
||||
@@ -81,7 +78,6 @@ enum OAuthStatus {
|
||||
OAUTH_STATUS_SUCCESS = 2; // OAuth complete, tokens available
|
||||
OAUTH_STATUS_FAILED = 3; // OAuth failed (user denied, etc.)
|
||||
OAUTH_STATUS_EXPIRED = 4; // State token expired (typically 10 minutes)
|
||||
OAUTH_STATUS_INVITATION_REQUIRED = 5; // New user but no valid invitation code provided
|
||||
}
|
||||
|
||||
// User information returned from auth endpoints
|
||||
|
||||
@@ -45,9 +45,6 @@ service Eagle {
|
||||
rpc DeleteGame(DeleteGameRequest) returns (DeleteGameResponse) {}
|
||||
rpc CheckGameExists(CheckGameExistsRequest) returns (CheckGameExistsResponse) {}
|
||||
rpc DownloadGameSave(DownloadGameSaveRequest) returns (stream DownloadGameSaveResponse) {}
|
||||
|
||||
// Server lifecycle management (for blue-green deployments)
|
||||
rpc ReloadGames(ReloadGamesRequest) returns (ReloadGamesResponse) {}
|
||||
}
|
||||
|
||||
message PostCommandRequest {
|
||||
@@ -608,12 +605,3 @@ message DownloadGameSaveRequest {
|
||||
message DownloadGameSaveResponse {
|
||||
bytes chunk = 1; // Chunk of zip file data
|
||||
}
|
||||
|
||||
// Server lifecycle management (for blue-green deployments)
|
||||
message ReloadGamesRequest {}
|
||||
|
||||
message ReloadGamesResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2;
|
||||
int32 games_reloaded = 3; // Number of games that were reloaded from disk
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user