Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6b37b5aac |
@@ -1,139 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: Build & Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.TEKTON_RELEASE_TOKEN }}
|
|
||||||
TAG_NAME: ${{ github.ref_name }}
|
|
||||||
steps:
|
|
||||||
- name: Install tools
|
|
||||||
run: apt-get update -qq && apt-get install -y -qq curl unzip
|
|
||||||
|
|
||||||
- name: Checkout Code
|
|
||||||
run: |
|
|
||||||
git config --global credential.helper store
|
|
||||||
echo "http://adtpdn:${{ secrets.TEKTON_RELEASE_TOKEN }}@52.74.133.55:3000" > ~/.git-credentials
|
|
||||||
git clone http://52.74.133.55:3000/danchie/tekton.git .
|
|
||||||
git checkout $TAG_NAME
|
|
||||||
|
|
||||||
- name: Setup Godot (Cached)
|
|
||||||
run: |
|
|
||||||
apt-get install -y zip
|
|
||||||
if [ ! -f /cache/godot_4.6 ]; then
|
|
||||||
echo "Downloading Godot 4.6..."
|
|
||||||
curl -sL -o /tmp/godot.zip "https://github.com/godotengine/godot-builds/releases/download/4.6-stable/Godot_v4.6-stable_linux.x86_64.zip"
|
|
||||||
unzip -q -o /tmp/godot.zip -d /cache/
|
|
||||||
mv /cache/Godot_v4.6-stable_linux.x86_64 /cache/godot_4.6
|
|
||||||
fi
|
|
||||||
cp /cache/godot_4.6 /usr/local/bin/godot
|
|
||||||
chmod +x /usr/local/bin/godot
|
|
||||||
mkdir -p ~/.local/share/godot/export_templates/4.6.stable
|
|
||||||
if [ ! -f /cache/Godot_v4.6-stable_export_templates.tpz ]; then
|
|
||||||
echo "Downloading templates..."
|
|
||||||
curl -sL -o /cache/Godot_v4.6-stable_export_templates.tpz \
|
|
||||||
"https://github.com/godotengine/godot-builds/releases/download/4.6-stable/Godot_v4.6-stable_export_templates.tpz"
|
|
||||||
fi
|
|
||||||
cd ~/.local/share/godot/export_templates/4.6.stable
|
|
||||||
unzip -q -o /cache/Godot_v4.6-stable_export_templates.tpz
|
|
||||||
mv templates/* .
|
|
||||||
rm -rf templates
|
|
||||||
cd $GITHUB_WORKSPACE
|
|
||||||
mkdir -p build
|
|
||||||
|
|
||||||
- name: Export Windows
|
|
||||||
run: |
|
|
||||||
mkdir -p build/windows
|
|
||||||
cp addons/godotsteam/libgodotsteam* build/windows/ 2>/dev/null || true
|
|
||||||
godot --headless --export-release "Windows Desktop" build/windows/tekton_armageddon_windows.exe || true
|
|
||||||
cd build/windows && zip -r ../tekton_armageddon_windows_${TAG_NAME}.zip .
|
|
||||||
|
|
||||||
- name: Export Linux
|
|
||||||
run: |
|
|
||||||
mkdir -p build/linux
|
|
||||||
godot --headless --export-release "Linux/X11" build/linux/tekton_armageddon_linux.x86_64 || true
|
|
||||||
cd build/linux && zip -r ../tekton_armageddon_linux_${TAG_NAME}.zip .
|
|
||||||
|
|
||||||
- name: Export macOS
|
|
||||||
run: |
|
|
||||||
mkdir -p build/macos
|
|
||||||
godot --headless --export-release "macOS" build/macos/tekton_armageddon_macos.zip 2>&1 | tail -5 || true
|
|
||||||
if [ -f build/macos/tekton_armageddon_macos.zip ]; then
|
|
||||||
mv build/macos/tekton_armageddon_macos.zip build/tekton_armageddon_macos_${TAG_NAME}.zip
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create Gitea Release
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
API="http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases"
|
|
||||||
TAG="$TAG_NAME"
|
|
||||||
echo "Checking existing release for $TAG..."
|
|
||||||
RELEASE_JSON=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$API/tags/$TAG" 2>/dev/null || echo "")
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*' || true)
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
echo "Creating new release for $TAG..."
|
|
||||||
RELEASE_JSON=$(curl -s -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":true}" \
|
|
||||||
"$API")
|
|
||||||
echo "API response: $RELEASE_JSON"
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
echo "FATAL: Could not create release"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "release_id=$RELEASE_ID"
|
|
||||||
echo "$RELEASE_ID" > /tmp/release_id.txt
|
|
||||||
|
|
||||||
- name: Upload Windows asset
|
|
||||||
run: |
|
|
||||||
RELEASE_ID=$(cat /tmp/release_id.txt)
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: multipart/form-data" \
|
|
||||||
-F "attachment=@build/tekton_armageddon_windows_${TAG_NAME}.zip" \
|
|
||||||
"http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
|
||||||
echo "Windows uploaded"
|
|
||||||
|
|
||||||
- name: Upload Linux asset
|
|
||||||
run: |
|
|
||||||
RELEASE_ID=$(cat /tmp/release_id.txt)
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: multipart/form-data" \
|
|
||||||
-F "attachment=@build/tekton_armageddon_linux_${TAG_NAME}.zip" \
|
|
||||||
"http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
|
||||||
echo "Linux uploaded"
|
|
||||||
|
|
||||||
- name: Upload macOS asset
|
|
||||||
run: |
|
|
||||||
RELEASE_ID=$(cat /tmp/release_id.txt)
|
|
||||||
if [ -f "build/tekton_armageddon_macos_${TAG_NAME}.zip" ]; then
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: multipart/form-data" \
|
|
||||||
-F "attachment=@build/tekton_armageddon_macos_${TAG_NAME}.zip" \
|
|
||||||
"http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID/assets"
|
|
||||||
echo "macOS uploaded"
|
|
||||||
else
|
|
||||||
echo "macOS asset not built, skipping"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Publish release
|
|
||||||
run: |
|
|
||||||
RELEASE_ID=$(cat /tmp/release_id.txt)
|
|
||||||
curl -s -X PATCH \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"draft":false}' \
|
|
||||||
"http://52.74.133.55:3000/api/v1/repos/danchie/tekton/releases/$RELEASE_ID"
|
|
||||||
echo "Published: https://git.klud.top/danchie/tekton/releases/tag/$TAG_NAME"
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
name: Deploy Patch
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: 'Patch version (e.g., 2.4.2)'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
notes:
|
|
||||||
description: 'Release notes'
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-patch:
|
|
||||||
name: Build Patch
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
env:
|
|
||||||
ACTIONS_STEP_DEBUG: true
|
|
||||||
steps:
|
|
||||||
- name: Uji Koneksi Jaringan & SSH
|
|
||||||
run: |
|
|
||||||
echo "Test Ping ke IP Tailscale Gitea..."
|
|
||||||
ping -c 3 100.79.174.108 || echo "PING GAGAL: Runner tidak bisa akses jaringan Tailscale."
|
|
||||||
|
|
||||||
echo "Test Koneksi Port SSH..."
|
|
||||||
nc -zv 100.79.174.108 222 || echo "PORT GAGAL: Port 222 tertutup dari dalam runner."
|
|
||||||
|
|
||||||
- name: Setup SSH config for Gitea
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo -e "Host git.klud.top\n HostName 100.79.174.108\n Port 222\n StrictHostKeyChecking no" > ~/.ssh/config
|
|
||||||
chmod 600 ~/.ssh/config
|
|
||||||
|
|
||||||
git config --global url."ssh://git@100.79.174.108:222/".insteadOf "git@git.klud.top:"
|
|
||||||
git config --global url."ssh://git@100.79.174.108:222/".insteadOf "https://git.klud.top/"
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
ssh-key: ${{ secrets.TEKTON_SSH_KEY }}
|
|
||||||
ssh-strict: false
|
|
||||||
|
|
||||||
- name: Setup Godot
|
|
||||||
run: |
|
|
||||||
if [ -f /cache/Godot_v4.6-stable_linux.x86_64.zip ]; then
|
|
||||||
cp -a /cache/Godot_v4.6-stable_linux.x86_64.zip godot.zip
|
|
||||||
else
|
|
||||||
wget -q https://github.com/godotengine/godot-builds/releases/download/4.6-stable/Godot_v4.6-stable_linux.x86_64.zip -O godot.zip
|
|
||||||
fi
|
|
||||||
unzip -q godot.zip
|
|
||||||
mv Godot_v4.6-stable_linux.x86_64 /usr/local/bin/godot
|
|
||||||
chmod +x /usr/local/bin/godot
|
|
||||||
|
|
||||||
- name: Install export templates
|
|
||||||
run: |
|
|
||||||
mkdir -v -p ~/.local/share/godot/export_templates/4.6.stable
|
|
||||||
if [ -f /cache/Godot_v4.6-stable_export_templates.tpz ]; then
|
|
||||||
cp -a /cache/Godot_v4.6-stable_export_templates.tpz /tmp/godot-templates.tpz
|
|
||||||
else
|
|
||||||
wget -nv https://github.com/godotengine/godot-builds/releases/download/4.6-stable/Godot_v4.6-stable_export_templates.tpz -O /tmp/godot-templates.tpz
|
|
||||||
fi
|
|
||||||
unzip -o -q /tmp/godot-templates.tpz -d /tmp/godot-templates
|
|
||||||
if [ -d /tmp/godot-templates/templates ]; then
|
|
||||||
cp -a /tmp/godot-templates/templates/* ~/.local/share/godot/export_templates/4.6.stable/
|
|
||||||
else
|
|
||||||
cp -a /tmp/godot-templates/* ~/.local/share/godot/export_templates/4.6.stable/
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run patch build script
|
|
||||||
env:
|
|
||||||
PATCH_VERSION: ${{ github.event.inputs.version }}
|
|
||||||
PATCH_NOTES: ${{ github.event.inputs.notes }}
|
|
||||||
run: |
|
|
||||||
python3 patch_version.py "$PATCH_VERSION" "$PATCH_NOTES"
|
|
||||||
|
|
||||||
- name: Export Windows patch
|
|
||||||
run: |
|
|
||||||
mkdir -p build
|
|
||||||
godot --headless --export-release "Windows Desktop" build/tekton_armageddon_patch_windows.exe
|
|
||||||
|
|
||||||
- name: Export Linux patch
|
|
||||||
run: |
|
|
||||||
mkdir -p build
|
|
||||||
godot --headless --export-release "Linux/X11" build/tekton_armageddon_patch_linux.x86_64
|
|
||||||
|
|
||||||
- name: Upload patch artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: tekton-patch-${{ github.event.inputs.version }}
|
|
||||||
path: |
|
|
||||||
build/tekton_armageddon_patch_windows.exe
|
|
||||||
build/tekton_armageddon_patch_linux.x86_64
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
deploy-patch:
|
|
||||||
name: Deploy Patch
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
needs: build-patch
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Download patch artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: tekton-patch-${{ github.event.inputs.version }}
|
|
||||||
path: artifacts/patch
|
|
||||||
|
|
||||||
- name: Get or create Gitea patch release
|
|
||||||
id: gitea_release
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.TEKTON_RELEASE_TOKEN }}
|
|
||||||
TAG_NAME: patch-${{ github.event.inputs.version }}
|
|
||||||
RELEASE_NAME: "Patch ${{ github.event.inputs.version }}"
|
|
||||||
RELEASE_NOTES: ${{ github.event.inputs.notes }}
|
|
||||||
run: |
|
|
||||||
API="https://git.klud.top/api/v1/repos/danchie/tekton/releases"
|
|
||||||
RELEASE_JSON=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$API/tags/$TAG_NAME")
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
|
|
||||||
if [ -z "$RELEASE_ID" ]; then
|
|
||||||
PAYLOAD=$(python3 -c "import json, os; print(json.dumps({'tag_name': os.environ['TAG_NAME'], 'name': os.environ['RELEASE_NAME'], 'body': os.environ['RELEASE_NOTES'], 'prerelease': True}))")
|
|
||||||
RELEASE_JSON=$(curl -s -X POST \
|
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$PAYLOAD" \
|
|
||||||
"$API")
|
|
||||||
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])")
|
|
||||||
fi
|
|
||||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Upload Windows patch asset
|
|
||||||
run: |
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: token ${{ secrets.TEKTON_RELEASE_TOKEN }}" \
|
|
||||||
-H "Content-Type: multipart/form-data" \
|
|
||||||
-F "attachment=@artifacts/patch/tekton_armageddon_patch_windows.exe" \
|
|
||||||
"https://git.klud.top/api/v1/repos/danchie/tekton/releases/${{ steps.gitea_release.outputs.release_id }}/assets"
|
|
||||||
|
|
||||||
- name: Upload Linux patch asset
|
|
||||||
run: |
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: token ${{ secrets.TEKTON_RELEASE_TOKEN }}" \
|
|
||||||
-H "Content-Type: multipart/form-data" \
|
|
||||||
-F "attachment=@artifacts/patch/tekton_armageddon_patch_linux.x86_64" \
|
|
||||||
"https://git.klud.top/api/v1/repos/danchie/tekton/releases/${{ steps.gitea_release.outputs.release_id }}/assets"
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
name: Build and Export
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to build (e.g., 2.4.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Source Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Godot
|
||||||
|
uses: chickensoft-games/setup-godot@v1
|
||||||
|
with:
|
||||||
|
version: '4.6.0'
|
||||||
|
use-dotnet: false
|
||||||
|
|
||||||
|
- name: Setup Export Templates
|
||||||
|
run: |
|
||||||
|
TEMPLATES_DIR=~/.local/share/godot/export_templates/4.6.stable
|
||||||
|
mkdir -p "$TEMPLATES_DIR"
|
||||||
|
wget -q https://github.com/godotengine/godot/releases/download/4.6-stable/Godot_v4.6-stable_export_templates.tpz -O templates.tpz
|
||||||
|
unzip -q templates.tpz -d "$TEMPLATES_DIR"
|
||||||
|
mv "$TEMPLATES_DIR/templates/"* "$TEMPLATES_DIR/"
|
||||||
|
rmdir "$TEMPLATES_DIR/templates"
|
||||||
|
|
||||||
|
- name: Export Windows Build
|
||||||
|
run: |
|
||||||
|
mkdir -p build
|
||||||
|
godot --headless --export-release "Windows Desktop" build/tekton_armageddon_windows.exe
|
||||||
|
|
||||||
|
- name: Zip Windows Build
|
||||||
|
run: cd build && zip tekton_armageddon_windows.zip tekton_armageddon_windows.exe
|
||||||
|
|
||||||
|
- name: Upload Windows Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-build
|
||||||
|
path: build/tekton_armageddon_windows.zip
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Source Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Godot
|
||||||
|
uses: chickensoft-games/setup-godot@v1
|
||||||
|
with:
|
||||||
|
version: '4.6.0'
|
||||||
|
use-dotnet: false
|
||||||
|
|
||||||
|
- name: Setup Export Templates
|
||||||
|
run: |
|
||||||
|
TEMPLATES_DIR=~/.local/share/godot/export_templates/4.6.stable
|
||||||
|
mkdir -p "$TEMPLATES_DIR"
|
||||||
|
wget -q https://github.com/godotengine/godot/releases/download/4.6-stable/Godot_v4.6-stable_export_templates.tpz -O templates.tpz
|
||||||
|
unzip -q templates.tpz -d "$TEMPLATES_DIR"
|
||||||
|
mv "$TEMPLATES_DIR/templates/"* "$TEMPLATES_DIR/"
|
||||||
|
rmdir "$TEMPLATES_DIR/templates"
|
||||||
|
|
||||||
|
- name: Export Linux Build
|
||||||
|
run: |
|
||||||
|
mkdir -p build
|
||||||
|
godot --headless --export-release "Linux/X11" build/tekton_armageddon_linux.x86_64
|
||||||
|
|
||||||
|
- name: Zip Linux Build
|
||||||
|
run: cd build && zip tekton_armageddon_linux.zip tekton_armageddon_linux.x86_64
|
||||||
|
|
||||||
|
- name: Upload Linux Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-build
|
||||||
|
path: build/tekton_armageddon_linux.zip
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
needs: [build-windows, build-linux]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: always() && startsWith(github.ref, 'refs/tags/')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Extract Version
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download All Artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
windows-build/tekton_armageddon_windows.zip
|
||||||
|
linux-build/tekton_armageddon_linux.zip
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Mirror to tekton-updates
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.PUBLIC_REPO_PAT }}
|
||||||
|
run: |
|
||||||
|
gh release create "v${{ steps.version.outputs.version }}" \
|
||||||
|
--repo "${{ github.actor }}/tekton-updates" \
|
||||||
|
--title "v${{ steps.version.outputs.version }}" \
|
||||||
|
--notes "Mirror of https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }}" \
|
||||||
|
"windows-build/tekton_armageddon_windows.zip#Windows" \
|
||||||
|
"linux-build/tekton_armageddon_linux.zip#Linux"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
name: Build Platform Artifacts
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to build (e.g., 2.4.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-artifacts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
platform:
|
||||||
|
- name: Windows
|
||||||
|
preset: "Windows Desktop"
|
||||||
|
extension: exe
|
||||||
|
- name: Linux
|
||||||
|
preset: "Linux/X11"
|
||||||
|
extension: x86_64
|
||||||
|
- name: Android
|
||||||
|
preset: "Android"
|
||||||
|
extension: apk
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Source Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Godot
|
||||||
|
uses: chickensoft-games/setup-godot@v1
|
||||||
|
with:
|
||||||
|
version: '4.6.0'
|
||||||
|
use-dotnet: false
|
||||||
|
|
||||||
|
- name: Setup Export Templates
|
||||||
|
run: |
|
||||||
|
TEMPLATES_DIR=~/.local/share/godot/export_templates/4.6.stable
|
||||||
|
mkdir -p "$TEMPLATES_DIR"
|
||||||
|
wget -q https://github.com/godotengine/godot/releases/download/4.6-stable/Godot_v4.6-stable_export_templates.tpz -O templates.tpz
|
||||||
|
unzip -q templates.tpz -d "$TEMPLATES_DIR"
|
||||||
|
mv "$TEMPLATES_DIR/templates/"* "$TEMPLATES_DIR/"
|
||||||
|
rmdir "$TEMPLATES_DIR/templates"
|
||||||
|
|
||||||
|
- name: Setup Android SDK (Android only)
|
||||||
|
if: matrix.platform.name == 'Android'
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Extract Version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
else
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Building version: $VERSION"
|
||||||
|
|
||||||
|
- name: Create Build Directory
|
||||||
|
run: mkdir -p build
|
||||||
|
|
||||||
|
- name: Export Game
|
||||||
|
run: |
|
||||||
|
godot --headless --export-release "${{ matrix.platform.preset }}" \
|
||||||
|
"build/tekton_armageddon_${{ matrix.platform.name }}_v${{ steps.version.outputs.version }}.${{ matrix.platform.extension }}"
|
||||||
|
|
||||||
|
- name: Generate Checksums
|
||||||
|
run: |
|
||||||
|
cd build
|
||||||
|
sha256sum tekton_armageddon_${{ matrix.platform.name }}_v${{ steps.version.outputs.version }}.${{ matrix.platform.extension }} \
|
||||||
|
> tekton_armageddon_${{ matrix.platform.name }}_v${{ steps.version.outputs.version }}.sha256
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: tekton-${{ matrix.platform.name }}-v${{ steps.version.outputs.version }}
|
||||||
|
path: |
|
||||||
|
build/tekton_armageddon_${{ matrix.platform.name }}_v${{ steps.version.outputs.version }}.${{ matrix.platform.extension }}
|
||||||
|
build/tekton_armageddon_${{ matrix.platform.name }}_v${{ steps.version.outputs.version }}.sha256
|
||||||
|
retention-days: 90
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
|
- name: Create Release Asset
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
build/tekton_armageddon_${{ matrix.platform.name }}_v${{ steps.version.outputs.version }}.${{ matrix.platform.extension }}
|
||||||
|
build/tekton_armageddon_${{ matrix.platform.name }}_v${{ steps.version.outputs.version }}.sha256
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
build-patch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Source Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Godot
|
||||||
|
uses: chickensoft-games/setup-godot@v1
|
||||||
|
with:
|
||||||
|
version: '4.6.0'
|
||||||
|
use-dotnet: false
|
||||||
|
|
||||||
|
- name: Extract Version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
else
|
||||||
|
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Generate Changed Files List
|
||||||
|
run: |
|
||||||
|
find scripts/ scenes/ assets/ -type f > changed_files.txt
|
||||||
|
echo "Patching $(wc -l < changed_files.txt) files"
|
||||||
|
|
||||||
|
- name: Build Patch PCK
|
||||||
|
run: godot --headless -s tools/build_patch.gd
|
||||||
|
|
||||||
|
- name: Generate Patch Checksum
|
||||||
|
run: |
|
||||||
|
sha256sum patch.pck > patch.pck.sha256
|
||||||
|
|
||||||
|
- name: Upload Patch Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: tekton-patch-v${{ steps.version.outputs.version }}
|
||||||
|
path: |
|
||||||
|
patch.pck
|
||||||
|
patch.pck.sha256
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Push to Updates Repository
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: dmnemec/copy_file_to_another_repo_action@main
|
||||||
|
env:
|
||||||
|
API_TOKEN_GITHUB: ${{ secrets.PUBLIC_REPO_PAT }}
|
||||||
|
with:
|
||||||
|
source_file: 'patch.pck'
|
||||||
|
destination_repo: '${{ github.actor }}/tekton-updates'
|
||||||
|
destination_folder: 'v${{ steps.version.outputs.version }}'
|
||||||
|
user_email: 'action@github.com'
|
||||||
|
user_name: 'PatchBot'
|
||||||
|
commit_message: '[AUTO] Release v${{ steps.version.outputs.version }} patch'
|
||||||
|
|
||||||
|
- name: Push Checksum to Updates Repository
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: dmnemec/copy_file_to_another_repo_action@main
|
||||||
|
env:
|
||||||
|
API_TOKEN_GITHUB: ${{ secrets.PUBLIC_REPO_PAT }}
|
||||||
|
with:
|
||||||
|
source_file: 'patch.pck.sha256'
|
||||||
|
destination_repo: '${{ github.actor }}/tekton-updates'
|
||||||
|
destination_folder: 'v${{ steps.version.outputs.version }}'
|
||||||
|
user_email: 'action@github.com'
|
||||||
|
user_name: 'PatchBot'
|
||||||
|
commit_message: '[AUTO] Release v${{ steps.version.outputs.version }} checksum'
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
name: Build and Release Patch PCK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'patch-release'
|
||||||
|
paths:
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'scenes/**'
|
||||||
|
- 'assets/**'
|
||||||
|
- 'CHANGELOG_DRAFT.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy-patch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Source Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# ── 1. Auto-generate version.json from CHANGELOG_DRAFT.md ────────────
|
||||||
|
- name: Generate Version JSON & Bump Version
|
||||||
|
run: python3 tools/generate_version_json.py
|
||||||
|
|
||||||
|
# ── 2. Commit bumped files back to the repo ───────────────────────────
|
||||||
|
- name: Commit Version Bump
|
||||||
|
run: |
|
||||||
|
git config user.name "PatchBot"
|
||||||
|
git config user.email "action@github.com"
|
||||||
|
git add assets/data/version.json project.godot CHANGELOG_DRAFT.md
|
||||||
|
git diff --staged --quiet || git commit -m "[AUTO] Version bump & changelog update"
|
||||||
|
git push
|
||||||
|
|
||||||
|
# ── 3. Detect changed files for patch PCK ────────────────────────────
|
||||||
|
- name: Generate Changed Files List
|
||||||
|
run: |
|
||||||
|
git diff --name-only HEAD^ HEAD -- 'scripts/**' 'scenes/**' 'assets/**' > changed_files.txt
|
||||||
|
echo "Files to patch:"
|
||||||
|
cat changed_files.txt
|
||||||
|
|
||||||
|
# ── 4. Build patch.pck ────────────────────────────────────────────────
|
||||||
|
- name: Setup Godot
|
||||||
|
uses: chickensoft-games/setup-godot@v1
|
||||||
|
with:
|
||||||
|
version: '4.6.0'
|
||||||
|
use-dotnet: false
|
||||||
|
|
||||||
|
- name: Run Build Patch Script
|
||||||
|
run: godot --headless -s tools/build_patch.gd
|
||||||
|
|
||||||
|
# ── 5. Generate checksums ─────────────────────────────────────────────────
|
||||||
|
- name: Generate Checksums
|
||||||
|
run: |
|
||||||
|
sha256sum patch.pck > patch.pck.sha256
|
||||||
|
sha256sum assets/data/version.json > version.json.sha256
|
||||||
|
|
||||||
|
# ── 6. Upload artifacts to GitHub ─────────────────────────────────────────
|
||||||
|
- name: Upload Patch Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: patch-pck-${{ github.sha }}
|
||||||
|
path: |
|
||||||
|
patch.pck
|
||||||
|
patch.pck.sha256
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Upload Version Manifest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: version-manifest-${{ github.sha }}
|
||||||
|
path: |
|
||||||
|
assets/data/version.json
|
||||||
|
version.json.sha256
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
# ── 7. Push patch.pck to public repo ─────────────────────────────────────
|
||||||
|
- name: Push patch.pck to Public Repository
|
||||||
|
uses: dmnemec/copy_file_to_another_repo_action@main
|
||||||
|
env:
|
||||||
|
API_TOKEN_GITHUB: ${{ secrets.PUBLIC_REPO_PAT }}
|
||||||
|
with:
|
||||||
|
source_file: 'patch.pck'
|
||||||
|
destination_repo: '${{ github.actor }}/tekton-updates'
|
||||||
|
destination_folder: 'latest'
|
||||||
|
user_email: 'action@github.com'
|
||||||
|
user_name: 'PatchBot'
|
||||||
|
commit_message: '[AUTO] Pushed new patch.pck'
|
||||||
|
|
||||||
|
- name: Push patch checksum to Public Repository
|
||||||
|
uses: dmnemec/copy_file_to_another_repo_action@main
|
||||||
|
env:
|
||||||
|
API_TOKEN_GITHUB: ${{ secrets.PUBLIC_REPO_PAT }}
|
||||||
|
with:
|
||||||
|
source_file: 'patch.pck.sha256'
|
||||||
|
destination_repo: '${{ github.actor }}/tekton-updates'
|
||||||
|
destination_folder: 'latest'
|
||||||
|
user_email: 'action@github.com'
|
||||||
|
user_name: 'PatchBot'
|
||||||
|
commit_message: '[AUTO] Pushed patch checksum'
|
||||||
|
|
||||||
|
# ── 8. Push version.json to public repo ──────────────────────────────────
|
||||||
|
- name: Push version.json to Public Repository
|
||||||
|
uses: dmnemec/copy_file_to_another_repo_action@main
|
||||||
|
env:
|
||||||
|
API_TOKEN_GITHUB: ${{ secrets.PUBLIC_REPO_PAT }}
|
||||||
|
with:
|
||||||
|
source_file: 'assets/data/version.json'
|
||||||
|
destination_repo: '${{ github.actor }}/tekton-updates'
|
||||||
|
destination_folder: 'latest'
|
||||||
|
user_email: 'action@github.com'
|
||||||
|
user_name: 'PatchBot'
|
||||||
|
commit_message: '[AUTO] Pushed new version.json'
|
||||||
|
|
||||||
|
- name: Push version checksum to Public Repository
|
||||||
|
uses: dmnemec/copy_file_to_another_repo_action@main
|
||||||
|
env:
|
||||||
|
API_TOKEN_GITHUB: ${{ secrets.PUBLIC_REPO_PAT }}
|
||||||
|
with:
|
||||||
|
source_file: 'version.json.sha256'
|
||||||
|
destination_repo: '${{ github.actor }}/tekton-updates'
|
||||||
|
destination_folder: 'latest'
|
||||||
|
user_email: 'action@github.com'
|
||||||
|
user_name: 'PatchBot'
|
||||||
|
commit_message: '[AUTO] Pushed version checksum'
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Automated Testing
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- 'feature/**'
|
||||||
|
- 'patch-release'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Source Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Godot
|
||||||
|
uses: chickensoft-games/setup-godot@v1
|
||||||
|
with:
|
||||||
|
version: '4.6.0'
|
||||||
|
use-dotnet: false
|
||||||
|
|
||||||
|
- name: Verify GUT Installation
|
||||||
|
run: |
|
||||||
|
if [ ! -d "addons/gut" ]; then
|
||||||
|
echo "ERROR: GUT addon not found at addons/gut"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "GUT addon found"
|
||||||
|
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: |
|
||||||
|
godot --headless --path . -s res://addons/gut/gut_cmdln.gd \
|
||||||
|
-gdir=res://tests/ \
|
||||||
|
-gexit \
|
||||||
|
-glog=2
|
||||||
|
|
||||||
|
- name: Check Test Results
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "Tests failed. Check logs above for details."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Upload Test Reports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-reports
|
||||||
|
path: test_reports/
|
||||||
|
retention-days: 30
|
||||||
@@ -4,10 +4,7 @@
|
|||||||
.agent/
|
.agent/
|
||||||
_daily_basis/
|
_daily_basis/
|
||||||
_daily_changes/
|
_daily_changes/
|
||||||
tools/gitea-kanban
|
|
||||||
build/
|
build/
|
||||||
label_mapping.json
|
|
||||||
milestone_mapping.json
|
|
||||||
|
|
||||||
/android/
|
/android/
|
||||||
.tmp
|
.tmp
|
||||||
|
|||||||
+1
-11
@@ -152,17 +152,7 @@
|
|||||||
- Fragment Craft system — collect drops to craft exclusive skins
|
- Fragment Craft system — collect drops to craft exclusive skins
|
||||||
- Fixed boot screen stuck on "Checking versions..."
|
- Fixed boot screen stuck on "Checking versions..."
|
||||||
|
|
||||||
## [2.4.2] — 2026-07-03
|
## [2.4.1] — $(date +"%Y-%m-%d")
|
||||||
- Replaced the **Cleanser** mechanic in Gauntlet mode with a **Ghost powerup** sticky-bypass system.
|
|
||||||
- Ghost (Invisible Mode) now lets players walk through sticky candy tiles in Gauntlet — no more hard block.
|
|
||||||
- Players earn a Ghost powerup every 2 completed missions in Gauntlet (replaces Cleanser charge grants).
|
|
||||||
- Ghost powerup tiles now spawn naturally on the Gauntlet arena (15% chance alongside common tiles).
|
|
||||||
- Removed Cleanser HUD elements (icon, label, charge counter) from the Gauntlet overlay.
|
|
||||||
- Removed `use_cleanser` input action — Ghost uses the existing powerup activation keybind.
|
|
||||||
- Bots now activate Ghost powerup when boxed in by sticky tiles instead of using Cleanser.
|
|
||||||
- Players pushed into sticky tiles while in Ghost mode are no longer slowed.
|
|
||||||
|
|
||||||
## [2.4.1] — 2026-06-28
|
|
||||||
- Fixed Gauntlet map layout to remove red unpassable barrier blocks and center blocks. They are now standard walkable floors but act as hard blockers in physics so players cannot pass them.
|
- Fixed Gauntlet map layout to remove red unpassable barrier blocks and center blocks. They are now standard walkable floors but act as hard blockers in physics so players cannot pass them.
|
||||||
- Fixed Gauntlet mode to prevent powerups or sticky bubbles from spawning on boundary tiles or under the central cannon.
|
- Fixed Gauntlet mode to prevent powerups or sticky bubbles from spawning on boundary tiles or under the central cannon.
|
||||||
- Center Candy Cannon now shoots actual projectiles that fly towards sticky cells and leave a VFX trail behind them.
|
- Center Candy Cannon now shoots actual projectiles that fly towards sticky cells and leave a VFX trail behind them.
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
# Cloning from any tailnet machine
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
- The remote machine must be on your tailnet (registered + can ping `100.85.x.x`)
|
|
||||||
- You must have an account on the Gitea instance (`http://git.klud.top`)
|
|
||||||
- You must have an SSH key on the remote machine (`ssh-keygen` if you don't)
|
|
||||||
|
|
||||||
## Step 1: Add your SSH key to Gitea
|
|
||||||
1. Open `http://git.klud.top/user/settings/keys` in browser
|
|
||||||
2. Paste the content of `~/.ssh/id_ed25519.pub` (or `id_rsa.pub`)
|
|
||||||
3. Click "Add Key"
|
|
||||||
|
|
||||||
## Step 2: Test SSH reachability
|
|
||||||
```bash
|
|
||||||
ssh -T git@thunderobot -p 222
|
|
||||||
# Expected output: "Hello from Gitea!\n\nPlease come over!"
|
|
||||||
# If asked about fingerprint, type "yes" and press Enter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3: Clone a repo
|
|
||||||
```bash
|
|
||||||
# Using tea CLI
|
|
||||||
tea repos clone --git-protocol ssh danchie/tekton
|
|
||||||
|
|
||||||
# Or plain git
|
|
||||||
git clone git@thunderobot:222/danchie/tekton.git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
- `ssh: connect to host thunderobot port 222: Connection refused`
|
|
||||||
→ Ensure the remote machine has `tailscale ping thunderobot` resolving to `100.93.226.13`
|
|
||||||
→ Ensure the host's port `222` is reachable from the remote (it's bound to `0.0.0.0:222` in Docker)
|
|
||||||
|
|
||||||
- `Permission denied (publickey)`
|
|
||||||
→ Your SSH key wasn't added via Gitea web UI
|
|
||||||
→ Run `ssh-add ~/.ssh/id_ed25519` if you use an agent
|
|
||||||
|
|
||||||
- `Host key verification failed`
|
|
||||||
→ Run `ssh -T git@thunderobot -p 222` interactively once to accept the fingerprint
|
|
||||||
|
|
||||||
## HTTPS is disabled
|
|
||||||
`DISABLE_HTTP_GIT = true` is set on the server. All clones MUST use SSH.
|
|
||||||
If you need to fetch via HTTPS for CI or other non-ssh environments, this must be remapped.
|
|
||||||
@@ -1,25 +1,141 @@
|
|||||||
# Tekton Dash Armageddon
|
# Tekton Dash Armageddon
|
||||||
|
|
||||||
> Full developer documentation lives in this repo's **wiki** (sidebar link).
|
|
||||||
>
|
|
||||||
> See in particular: [Skin Creation Workflow](https://git.klud.top/danchie/tekton/wiki/Skin-Creation-Workflow), [Nakama Deployment](https://git.klud.top/danchie/tekton/wiki/Nakama-Deployment), and [Patch Release Workflow](https://git.klud.top/danchie/tekton/wiki/Patch-Release-Workflow).
|
|
||||||
|
|
||||||
## SSH setup for cloning
|
## 🛠️ Developer Workflows
|
||||||
|
|
||||||
All clones **must use SSH** over Tailscale. HTTPS is disabled on this instance.
|
### Creating a Skin Material
|
||||||
|
To create dynamic, color-maskable 3D materials for new character skins:
|
||||||
|
- Open the **Skin Shader Generator** tool in the editor: `res://scenes/tools/skin_shader_generator.tscn`
|
||||||
|
- Run the scene.
|
||||||
|
- Import your base albedo and mask textures.
|
||||||
|
- Use the UI to visualize UV overlays and adjust color channels (Red, Green, Blue, Alpha masks).
|
||||||
|
- Export the configured material as a `.tres` file into the `assets/materials/skins/` directory.
|
||||||
|
|
||||||
Guides by operating system:
|
### Adding a Skin to the Shop
|
||||||
|
Once your material is ready, you need to update the game's catalog and deploy the changes to the Nakama server.
|
||||||
|
|
||||||
- [Linux](https://git.klud.top/danchie/tekton/wiki/SSH-Setup-Linux)
|
#### Using the Catalog Editor Tool
|
||||||
- [macOS](https://git.klud.top/danchie/tekton/wiki/SSH-Setup-macOS)
|
- Open the **Skin Catalog Editor** tool in the Godot Editor: `res://scenes/tools/skin_catalog_editor.tscn`
|
||||||
- [Windows](https://git.klud.top/danchie/tekton/wiki/SSH-Setup-Windows)
|
- Press **F6** (or Right-click -> Run Current Scene).
|
||||||
|
- **Manage Skins:**
|
||||||
|
- Click **"+ New Skin"** to create a new entry.
|
||||||
|
- Fill in the **ID**, **Name**, **Category**, and **Price** (Gold/Stars).
|
||||||
|
- Assign the `.tres` material path generated in Step 1.
|
||||||
|
- Click **"💾 Save & Generate"**. This automatically rewrites:
|
||||||
|
- `res://scripts/managers/skin_manager.gd` (Local catalog)
|
||||||
|
- `res://server/nakama/tekton_admin.js` (Server-side shop logic)
|
||||||
|
|
||||||
Quick verification after setup:
|
#### Nakama VPS Deployment
|
||||||
```bash
|
After generating the updated `tekton_admin.js` locally, you must sync it with your remote server.
|
||||||
ssh -T git@thunderobot -p 222
|
|
||||||
|
- **Copy the latest script:** Open `server/nakama/tekton_admin.js` locally and copy its updated contents (including your new skin).
|
||||||
|
- **Connect to your VPS** via SSH.
|
||||||
|
- **Create/Edit the file on the remote server:**
|
||||||
|
```bash
|
||||||
|
nano ~/tekton_admin.js
|
||||||
|
# Or use micro (recommended): micro ~/tekton_admin.js
|
||||||
|
```
|
||||||
|
Paste the copied contents and save the file.
|
||||||
|
- **Find your Nakama Container ID:**
|
||||||
|
It is highly recommended to use **lazydocker** to manage containers.
|
||||||
|
*(To install on Ubuntu: `curl https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash`)*
|
||||||
|
Open lazydocker or run `docker ps` to find the Container ID for your Nakama server.
|
||||||
|
- **Copy the file into the Nakama container:**
|
||||||
|
```bash
|
||||||
|
# Replace ed21ac5d442a with your actual Container ID
|
||||||
|
docker cp ~/tekton_admin.js ed21ac5d442a:/nakama/data/modules/tekton_admin.js
|
||||||
|
```
|
||||||
|
- **Restart the container** (via lazydocker or `docker restart <Container ID>`) for Nakama to load the new modules. Live game clients will fetch this new catalog automatically upon booting.
|
||||||
|
|
||||||
|
#### 🎨 Skin Creation & Deployment Flow
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
%% Creation Phase
|
||||||
|
subgraph phase1 [Skin Material Creation]
|
||||||
|
A[Albedo & Mask Textures] --> B{Skin Shader Generator}
|
||||||
|
B -->|Export| C[material.tres]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Catalog Phase
|
||||||
|
subgraph phase2 [Catalog Definition]
|
||||||
|
C --> D{Skin Catalog Editor}
|
||||||
|
D -->|Save & Generate| E[tekton_admin.js]
|
||||||
|
D -->|Save & Generate| F[skin_manager.gd]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Deployment Phase (Dual Path)
|
||||||
|
subgraph phase3 ["Shop Backend (VPS)"]
|
||||||
|
E -->|SSH & nano| G[VPS: ~/tekton_admin.js]
|
||||||
|
G -->|docker cp| H[Nakama Container]
|
||||||
|
H -->|Restart| I[Live Shop Logic Sync]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph phase4 ["Asset Delivery (CI/CD)"]
|
||||||
|
C --> J[Git Push]
|
||||||
|
F --> J
|
||||||
|
J -->|GitHub Actions| K[patch.pck]
|
||||||
|
K -->|Automatic Download| L[Player Client Assets Sync]
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Once verified, clone:
|
### Pushing a New Version (Automated Patching)
|
||||||
```bash
|
When you're ready to deploy new features or assets to players:
|
||||||
git clone git@thunderobot:222/danchie/tekton.git
|
- Document your changes in `CHANGELOG_DRAFT.md` using player-friendly language.
|
||||||
|
- Run the version generation script from the terminal:
|
||||||
|
```bash
|
||||||
|
python generate_version_json.py --bump patch
|
||||||
|
```
|
||||||
|
*(Use `--bump minor` or `--bump major` for larger updates.)*
|
||||||
|
- Commit and push your changes to the `main` branch on GitHub:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "Release version X.Y.Z"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
- The **GitHub Actions Workflow** (`deploy_patch.yml`) will automatically detect the push, build the patch manifest (`version.json`), and deploy it to the public `gh-pages` branch.
|
||||||
|
- Live game clients will detect the new version on boot, download the updated files, and apply the patch seamlessly.
|
||||||
|
|
||||||
|
### Local Testing & Understanding the Patch System
|
||||||
|
When a player (or you) downloads an in-game patch, Godot downloads a `patch.pck` file to the system's `user://` directory.
|
||||||
|
- **Virtual File System:** Godot mounts this `.pck` file over the `res://` directory purely in memory. **It does not physically overwrite your local source files** (like `assets/data/version.json`).
|
||||||
|
- **Editor Bypass:** When testing locally in the Godot Editor, the `BootScreen` is configured to skip the remote network download and instead automatically parse your *local* `assets/data/version.json`.
|
||||||
|
- **Previewing Changelogs:** To preview how your changelog will look before pushing to GitHub:
|
||||||
|
- Add your notes under the `## [NEXT]` section in `CHANGELOG_DRAFT.md`.
|
||||||
|
- Run `py tools/generate_version_json.py` from the terminal.
|
||||||
|
- Run the `BootScreen` scene in the Godot Editor. It will instantly display the updated local UI!
|
||||||
|
- **Syncing:** After the GitHub Actions CI builds a release online, remember to run `git pull origin main` to sync your local project files with the CI-generated files.
|
||||||
|
|
||||||
|
#### 🗺️ Architecture Flowchart
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
%% Local Dev Flow
|
||||||
|
subgraph Local Dev Environment
|
||||||
|
A[📝 CHANGELOG_DRAFT.md] -->|py tools/generate_version_json.py| B[(📄 version.json)]
|
||||||
|
B -.->|Test in Editor| C{BootScreen}
|
||||||
|
C -- Editor Bypass --> D[Reads Local version.json directly]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% CI Pipeline
|
||||||
|
subgraph "CI/CD Pipeline"
|
||||||
|
B -->|git push| E[⚡ GitHub Actions]
|
||||||
|
E -->|Compiles & Builds| F[📦 patch.pck]
|
||||||
|
F -->|Deploys| G((🌍 Public Repository))
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Live Client Flow
|
||||||
|
subgraph Player Client
|
||||||
|
G -.->|HTTP Download on Boot| H[📂 user://patch.pck]
|
||||||
|
H -->|ProjectSettings.load_resource_pack| I[🧠 Godot Virtual File System]
|
||||||
|
I -.->|Overrides res:// in Memory| J[Game Starts Updated!]
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Ongoing Features (Incoming)
|
||||||
|
|
||||||
|
### 🎰 Gacha System Backend Editor
|
||||||
|
Currently in development: A dedicated backend editor tool (similar to `skin_catalog_editor.tscn`) specifically for managing the Gacha System.
|
||||||
|
- **Nakama Syncing:** Will allow developers to push updated gacha pools, rates, and fragment costs directly to Nakama Storage.
|
||||||
|
- **Dynamic Banners:** Will support updating specific slots on the gacha banner dynamically.
|
||||||
|
- **Seasonal Rotations:** Will introduce automated scheduling so banners rotate based on active seasons and automatically remove themselves when the season ends.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ The plugin auto-starts the MCP server and connects over WebSocket. No manual con
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Godot 4.3+ (4.4+ recommended)
|
- Godot 4.3+ (4.7+ recommended)
|
||||||
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
|
- [uv](https://docs.astral.sh/uv/) (used to install the Python server)
|
||||||
<details>
|
<details>
|
||||||
<summary>Install uv</summary>
|
<summary>Install uv</summary>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd")
|
|||||||
const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
|
const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd")
|
||||||
const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
|
const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd")
|
||||||
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd")
|
||||||
|
const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd")
|
||||||
|
|
||||||
const SERVER_NAME := "godot-ai"
|
const SERVER_NAME := "godot-ai"
|
||||||
|
|
||||||
@@ -37,8 +38,14 @@ const DEFAULT_WS_PORT := 9500
|
|||||||
const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
|
const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE"
|
||||||
const MIN_PORT := 1024
|
const MIN_PORT := 1024
|
||||||
const MAX_PORT := 65535
|
const MAX_PORT := 65535
|
||||||
|
## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a
|
||||||
|
## pathological run of occupied ports can't stall the (cold-path) caller.
|
||||||
|
## 64 localhost binds are sub-millisecond; finding a free port realistically
|
||||||
|
## takes one or two probes, so this only bounds the worst case.
|
||||||
|
const SUGGEST_PORT_MAX_PROBES := 64
|
||||||
const SETTING_WS_PORT := "godot_ai/ws_port"
|
const SETTING_WS_PORT := "godot_ai/ws_port"
|
||||||
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
|
const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing"
|
||||||
|
const _DISCOVERY_TIMEOUT_MS := 3000
|
||||||
|
|
||||||
|
|
||||||
## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
|
## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`.
|
||||||
@@ -131,15 +138,39 @@ static func excluded_domains() -> String:
|
|||||||
return ",".join(parts)
|
return ",".join(parts)
|
||||||
|
|
||||||
|
|
||||||
## Clamp `start` into the legal port range, then walk
|
## Suggest a port the caller can actually switch to. Walks
|
||||||
## `candidate`..`candidate+span-1` and return the first port that is NOT
|
## `candidate`..`candidate+span-1` and returns the first port that is both
|
||||||
## currently excluded by Windows' winnat reservation table. Falls back to the
|
## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker
|
||||||
## clamped candidate if nothing clears (caller can apply anyway — user may
|
## grab these; bind fails with WinError 10013 and netstat shows nothing) and
|
||||||
## just retry). On non-Windows this is a no-op: all ports pass, returns the
|
## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes
|
||||||
## clamped candidate.
|
## "free" honest on macOS/Linux, where the reservation table is empty but the
|
||||||
|
## next port up may still be occupied — the same suggestion feeds the dock
|
||||||
|
## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE
|
||||||
|
## log line. Falls back to the clamped candidate if nothing in the window
|
||||||
|
## clears both checks (caller surfaces it as a best-effort hint; the user can
|
||||||
|
## retry or pick another). Best-effort by nature: a TOCTOU window remains
|
||||||
|
## between the probe and the caller actually binding the port. The bind probe
|
||||||
|
## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't
|
||||||
|
## stall on a pathological run of occupied ports.
|
||||||
static func suggest_free_port(start: int, span: int = 2048) -> int:
|
static func suggest_free_port(start: int, span: int = 2048) -> int:
|
||||||
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
|
var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1)
|
||||||
return WindowsPortReservation.suggest_non_excluded_port(candidate, span, MAX_PORT)
|
var limit := mini(candidate + span - 1, MAX_PORT)
|
||||||
|
var p := candidate
|
||||||
|
var probes := 0
|
||||||
|
while p <= limit and probes < SUGGEST_PORT_MAX_PROBES:
|
||||||
|
## Jump past a whole Windows-reserved range in one step (no-op on
|
||||||
|
## POSIX: returns `p` unchanged), so we don't probe port-by-port
|
||||||
|
## through the large adjacent ranges those services reserve. The
|
||||||
|
## jump itself runs no bind probes, so it doesn't count against the cap.
|
||||||
|
var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT)
|
||||||
|
if not_reserved < p or not_reserved > limit:
|
||||||
|
break
|
||||||
|
p = not_reserved
|
||||||
|
probes += 1
|
||||||
|
if PortResolver.can_bind_local_port(p):
|
||||||
|
return p
|
||||||
|
p += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
# --- Client operations (string id) ---------------------------------------
|
# --- Client operations (string id) ---------------------------------------
|
||||||
@@ -509,8 +540,8 @@ static func invalidate_uvx_cli_cache() -> void:
|
|||||||
## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
|
## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched`
|
||||||
## with a mutex so it can race safely against worker threads calling
|
## with a mutex so it can race safely against worker threads calling
|
||||||
## `find()` from `_run_client_action_worker`. The mutex is held only
|
## `find()` from `_run_client_action_worker`. The mutex is held only
|
||||||
## across the dictionary clear, never across `OS.execute`, so this call
|
## across the dictionary clear, never across the bounded subprocess lookup,
|
||||||
## can never block the main thread on a subprocess.
|
## so this call can never block the main thread on a subprocess.
|
||||||
static func invalidate_cli_cache() -> void:
|
static func invalidate_cli_cache() -> void:
|
||||||
CliFinder.invalidate()
|
CliFinder.invalidate()
|
||||||
|
|
||||||
@@ -521,10 +552,9 @@ static var _uv_version_searched: bool = false
|
|||||||
|
|
||||||
## Cached for the editor session. The dock's `_refresh_setup_status`
|
## Cached for the editor session. The dock's `_refresh_setup_status`
|
||||||
## (called via `call_deferred` from `_build_ui`) calls this on the
|
## (called via `call_deferred` from `_build_ui`) calls this on the
|
||||||
## main thread in user mode, so a single cold `OS.execute(uvx,
|
## main thread in user mode, so the cold `uvx --version` probe is
|
||||||
## ["--version"])` adds ~80 ms to the dock's first paint on Linux and
|
## wall-clock bounded and cached. Subsequent calls (focus-in refresh,
|
||||||
## more on Windows. Subsequent calls (focus-in refresh, manual Refresh
|
## manual Refresh clicks) reuse the cached string.
|
||||||
## clicks) reuse the cached string.
|
|
||||||
##
|
##
|
||||||
## Invalidate via `invalidate_uv_version_cache()` when the user
|
## Invalidate via `invalidate_uv_version_cache()` when the user
|
||||||
## installs / reinstalls uv via the dock so the next refresh reflects
|
## installs / reinstalls uv via the dock so the next refresh reflects
|
||||||
@@ -539,9 +569,10 @@ static func check_uv_version() -> String:
|
|||||||
_uv_version_searched = true
|
_uv_version_searched = true
|
||||||
_uv_version_cache = ""
|
_uv_version_cache = ""
|
||||||
return ""
|
return ""
|
||||||
var output: Array = []
|
var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false)
|
||||||
if OS.execute(uvx, ["--version"], output, true) == 0 and output.size() > 0:
|
if int(result.get("exit_code", -1)) == 0:
|
||||||
_uv_version_cache = output[0].strip_edges()
|
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
||||||
|
_uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else ""
|
||||||
else:
|
else:
|
||||||
_uv_version_cache = ""
|
_uv_version_cache = ""
|
||||||
_uv_version_searched = true
|
_uv_version_searched = true
|
||||||
@@ -612,9 +643,12 @@ static func find_worktree_src_dir(start_dir: String) -> String:
|
|||||||
|
|
||||||
static func _find_system_install() -> String:
|
static func _find_system_install() -> String:
|
||||||
var cmd := "which" if OS.get_name() != "Windows" else "where"
|
var cmd := "which" if OS.get_name() != "Windows" else "where"
|
||||||
var output: Array = []
|
var result := McpCliExec.run(cmd, ["godot-ai"], _DISCOVERY_TIMEOUT_MS, false)
|
||||||
if OS.execute(cmd, ["godot-ai"], output, true) == 0 and output.size() > 0:
|
if int(result.get("exit_code", -1)) == 0:
|
||||||
var found: String = output[0].strip_edges()
|
var lines := PackedStringArray(str(result.get("stdout", "")).split("\n"))
|
||||||
|
if lines.is_empty():
|
||||||
|
return ""
|
||||||
|
var found := CliFinder._pick_best_path(lines) if OS.get_name() == "Windows" else lines[0].strip_edges()
|
||||||
if not found.is_empty():
|
if not found.is_empty():
|
||||||
return found
|
return found
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ extends RefCounted
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS := 8000
|
const DEFAULT_TIMEOUT_MS := 8000
|
||||||
const _POLL_INTERVAL_MS := 50
|
const _POLL_INTERVAL_MS := 50
|
||||||
|
const _KILL_GRACE_MS := 500
|
||||||
|
|
||||||
|
|
||||||
static func run(
|
static func run(
|
||||||
@@ -44,6 +45,21 @@ static func run(
|
|||||||
) -> Dictionary:
|
) -> Dictionary:
|
||||||
if exe.is_empty():
|
if exe.is_empty():
|
||||||
return _spawn_failed_result()
|
return _spawn_failed_result()
|
||||||
|
if _uses_blocking_legacy_path():
|
||||||
|
## Godot 4.3 keeps the old blocking path because execute_with_pipe
|
||||||
|
## capture/exit semantics differ there. The bounded timeout/kill
|
||||||
|
## behavior is available on Godot 4.4+ only.
|
||||||
|
return _run_blocking_legacy(exe, args)
|
||||||
|
|
||||||
|
return _run_piped(exe, args, timeout_ms, capture_stderr)
|
||||||
|
|
||||||
|
|
||||||
|
static func _run_piped(
|
||||||
|
exe: String,
|
||||||
|
args: Array,
|
||||||
|
timeout_ms: int,
|
||||||
|
capture_stderr: bool,
|
||||||
|
) -> Dictionary:
|
||||||
|
|
||||||
var spawn_exe := exe
|
var spawn_exe := exe
|
||||||
var spawn_args := args
|
var spawn_args := args
|
||||||
@@ -76,12 +92,18 @@ static func run(
|
|||||||
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
|
var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS)
|
||||||
while OS.is_process_running(pid):
|
while OS.is_process_running(pid):
|
||||||
if Time.get_ticks_msec() >= deadline:
|
if Time.get_ticks_msec() >= deadline:
|
||||||
## Read whatever made it to the pipes before we kill the
|
## Kill before draining: a pipe read can block while the child is
|
||||||
## process — partial output beats blank "timed out" when the
|
## still alive. Once it exits, drain any buffered partial output.
|
||||||
## CLI was emitting useful diagnostics on its way to hanging.
|
|
||||||
var partial_stdout := _drain_pipe(stdio)
|
|
||||||
var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else ""
|
|
||||||
OS.kill(pid)
|
OS.kill(pid)
|
||||||
|
var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS
|
||||||
|
while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline:
|
||||||
|
OS.delay_msec(_POLL_INTERVAL_MS)
|
||||||
|
|
||||||
|
var partial_stdout := ""
|
||||||
|
var partial_stderr := ""
|
||||||
|
if not OS.is_process_running(pid):
|
||||||
|
partial_stdout = _drain_pipe(stdio)
|
||||||
|
partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else ""
|
||||||
_close_pipes(stdio, stderr_pipe)
|
_close_pipes(stdio, stderr_pipe)
|
||||||
return {
|
return {
|
||||||
"exit_code": -1,
|
"exit_code": -1,
|
||||||
@@ -107,6 +129,27 @@ static func run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func _run_blocking_legacy(exe: String, args: Array) -> Dictionary:
|
||||||
|
## Godot 4.3's OS.execute_with_pipe has capture/exit-code differences
|
||||||
|
## locked by the 4.3 canary skips in test_cli_exec.gd. Preserve the old
|
||||||
|
## blocking discovery behavior there so startup-critical probes keep the
|
||||||
|
## same semantics that worked before the bounded-pipe path landed.
|
||||||
|
var output: Array = []
|
||||||
|
var exit_code := OS.execute(exe, args, output, true)
|
||||||
|
var lines := PackedStringArray()
|
||||||
|
for line in output:
|
||||||
|
lines.append(str(line))
|
||||||
|
var stdout := "\n".join(lines)
|
||||||
|
return {
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"stdout": stdout,
|
||||||
|
"stderr": "",
|
||||||
|
"output": stdout,
|
||||||
|
"timed_out": false,
|
||||||
|
"spawn_failed": exit_code == -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static func _spawn_failed_result() -> Dictionary:
|
static func _spawn_failed_result() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"exit_code": -1,
|
"exit_code": -1,
|
||||||
@@ -119,9 +162,19 @@ static func _spawn_failed_result() -> Dictionary:
|
|||||||
|
|
||||||
|
|
||||||
static func _drain_pipe(pipe: Variant) -> String:
|
static func _drain_pipe(pipe: Variant) -> String:
|
||||||
if pipe is FileAccess:
|
if not (pipe is FileAccess):
|
||||||
return (pipe as FileAccess).get_as_text()
|
return ""
|
||||||
return ""
|
var f := pipe as FileAccess
|
||||||
|
var bytes := PackedByteArray()
|
||||||
|
var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output.
|
||||||
|
while bytes.size() < max_bytes:
|
||||||
|
var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size()))
|
||||||
|
if chunk.is_empty():
|
||||||
|
break
|
||||||
|
bytes.append_array(chunk)
|
||||||
|
if f.eof_reached():
|
||||||
|
break
|
||||||
|
return bytes.get_string_from_utf8()
|
||||||
|
|
||||||
|
|
||||||
static func _join_streams(stdout: String, stderr_text: String) -> String:
|
static func _join_streams(stdout: String, stderr_text: String) -> String:
|
||||||
@@ -141,3 +194,10 @@ static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void:
|
|||||||
(stdio as FileAccess).close()
|
(stdio as FileAccess).close()
|
||||||
if stderr_pipe is FileAccess:
|
if stderr_pipe is FileAccess:
|
||||||
(stderr_pipe as FileAccess).close()
|
(stderr_pipe as FileAccess).close()
|
||||||
|
|
||||||
|
|
||||||
|
static func _uses_blocking_legacy_path() -> bool:
|
||||||
|
var version := Engine.get_version_info()
|
||||||
|
var major := int(version.get("major", 4))
|
||||||
|
var minor := int(version.get("minor", 0))
|
||||||
|
return major < 4 or (major == 4 and minor < 4)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ extends RefCounted
|
|||||||
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
|
## the main thread (manual Refresh path). Godot `Dictionary` is not safe for
|
||||||
## concurrent mutation, so `_cache` / `_searched` access is guarded by
|
## concurrent mutation, so `_cache` / `_searched` access is guarded by
|
||||||
## `_mutex`. The mutex is held only across dictionary read/write — the slow
|
## `_mutex`. The mutex is held only across dictionary read/write — the slow
|
||||||
## `_resolve()` path (FileAccess + `OS.execute`) runs unlocked, so a
|
## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a
|
||||||
## main-thread `invalidate()` can never block on a worker's subprocess.
|
## main-thread `invalidate()` can never block on a worker's subprocess.
|
||||||
## Two workers racing the same exe both call `_resolve()` and both write
|
## Two workers racing the same exe both call `_resolve()` and both write
|
||||||
## back the same answer; that's wasted work, not corruption.
|
## back the same answer; that's wasted work, not corruption.
|
||||||
@@ -24,6 +24,8 @@ static var _mutex: Mutex = Mutex.new()
|
|||||||
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
|
static var _cache: Dictionary = {} # exe_name -> resolved path (or "")
|
||||||
static var _searched: Dictionary = {}
|
static var _searched: Dictionary = {}
|
||||||
|
|
||||||
|
const _LOOKUP_TIMEOUT_MS := 3000
|
||||||
|
|
||||||
|
|
||||||
## Find any of the supplied exe names; returns the first hit.
|
## Find any of the supplied exe names; returns the first hit.
|
||||||
## On Windows pass the .exe variant in `exe_names` if relevant.
|
## On Windows pass the .exe variant in `exe_names` if relevant.
|
||||||
@@ -54,8 +56,8 @@ static func _find_one(exe_name: String) -> String:
|
|||||||
_mutex.unlock()
|
_mutex.unlock()
|
||||||
if already_searched:
|
if already_searched:
|
||||||
return cached
|
return cached
|
||||||
# `_resolve()` does FileAccess + `OS.execute` (forks `bash -lc` /
|
# `_resolve()` does FileAccess + bounded subprocess lookup (forks
|
||||||
# `which`), which can take 100ms-1s. Holding the mutex across that
|
# `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that
|
||||||
# would let a concurrent `invalidate()` on the main thread freeze the
|
# would let a concurrent `invalidate()` on the main thread freeze the
|
||||||
# editor for the duration of the subprocess — which defeats the whole
|
# editor for the duration of the subprocess — which defeats the whole
|
||||||
# point of running CLI lookup off the main thread.
|
# point of running CLI lookup off the main thread.
|
||||||
@@ -81,20 +83,19 @@ static func _resolve(exe_name: String) -> String:
|
|||||||
var shell := OS.get_environment("SHELL")
|
var shell := OS.get_environment("SHELL")
|
||||||
if shell.is_empty():
|
if shell.is_empty():
|
||||||
shell = "/bin/bash"
|
shell = "/bin/bash"
|
||||||
var login_output: Array = []
|
|
||||||
var stripped := exe_name.trim_suffix(".exe")
|
var stripped := exe_name.trim_suffix(".exe")
|
||||||
var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true)
|
var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false)
|
||||||
if login_exit == 0 and login_output.size() > 0:
|
if int(login_result.get("exit_code", -1)) == 0:
|
||||||
var login_found: String = login_output[0].strip_edges()
|
var login_found: String = str(login_result.get("stdout", "")).strip_edges()
|
||||||
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
if not login_found.is_empty() and FileAccess.file_exists(login_found):
|
||||||
return login_found
|
return login_found
|
||||||
|
|
||||||
# 3. which / where with inherited PATH
|
# 3. which / where with inherited PATH
|
||||||
var lookup := "where" if is_windows else "which"
|
var lookup := "where" if is_windows else "which"
|
||||||
var output: Array = []
|
var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false)
|
||||||
var exit_code := OS.execute(lookup, [exe_name], output, true)
|
if int(result.get("exit_code", -1)) == 0:
|
||||||
if exit_code == 0 and output.size() > 0:
|
var output := str(result.get("stdout", ""))
|
||||||
var lines := PackedStringArray(output[0].split("\n"))
|
var lines := PackedStringArray(output.split("\n"))
|
||||||
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges()
|
||||||
if not found.is_empty():
|
if not found.is_empty():
|
||||||
return found
|
return found
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const EVAL_PROBE_INTERVAL_SEC := 0.35
|
|||||||
|
|
||||||
var _log_buffer: McpLogBuffer
|
var _log_buffer: McpLogBuffer
|
||||||
var _game_log_buffer: McpGameLogBuffer
|
var _game_log_buffer: McpGameLogBuffer
|
||||||
|
var _editor_log_buffer: McpEditorLogBuffer
|
||||||
|
|
||||||
## Pending request_id -> {connection, timer, timeout_callable}.
|
## Pending request_id -> {connection, timer, timeout_callable}.
|
||||||
## We retain the bound timeout lambda so `_clear_pending` can disconnect
|
## We retain the bound timeout lambda so `_clear_pending` can disconnect
|
||||||
@@ -80,12 +81,16 @@ var _game_run_token := 0
|
|||||||
var _ready_run_token := -1
|
var _ready_run_token := -1
|
||||||
var _game_session_id := -1
|
var _game_session_id := -1
|
||||||
var _game_run_active := false
|
var _game_run_active := false
|
||||||
|
var _game_run_started_msec := 0
|
||||||
|
var _game_run_started_editor_cursor := 0
|
||||||
|
var _game_helper_expected := true
|
||||||
signal game_ready
|
signal game_ready
|
||||||
|
|
||||||
|
|
||||||
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void:
|
func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null) -> void:
|
||||||
_log_buffer = log_buffer
|
_log_buffer = log_buffer
|
||||||
_game_log_buffer = game_log_buffer
|
_game_log_buffer = game_log_buffer
|
||||||
|
_editor_log_buffer = editor_log_buffer
|
||||||
|
|
||||||
|
|
||||||
func _has_capture(prefix: String) -> bool:
|
func _has_capture(prefix: String) -> bool:
|
||||||
@@ -107,14 +112,23 @@ func _setup_session(session_id: int) -> void:
|
|||||||
_game_session_id = session_id
|
_game_session_id = session_id
|
||||||
|
|
||||||
|
|
||||||
func begin_game_run() -> void:
|
func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void:
|
||||||
_game_run_token += 1
|
_game_run_token += 1
|
||||||
_game_run_active = true
|
_game_run_active = true
|
||||||
_game_ready = false
|
_game_ready = false
|
||||||
_ready_run_token = -1
|
_ready_run_token = -1
|
||||||
_game_session_id = -1
|
_game_session_id = -1
|
||||||
|
_game_run_started_msec = Time.get_ticks_msec()
|
||||||
|
_game_run_started_editor_cursor = maxi(0, editor_log_cursor)
|
||||||
|
_game_helper_expected = helper_expected
|
||||||
|
var run_id := ""
|
||||||
|
if _game_log_buffer:
|
||||||
|
run_id = _game_log_buffer.clear_for_new_run()
|
||||||
if _log_buffer:
|
if _log_buffer:
|
||||||
_log_buffer.log("[debug] game capture pending run token %d" % _game_run_token)
|
var log_text := "[debug] game capture pending run token %d" % _game_run_token
|
||||||
|
if not run_id.is_empty():
|
||||||
|
log_text += " (run %s)" % run_id
|
||||||
|
_log_buffer.log(log_text)
|
||||||
|
|
||||||
|
|
||||||
func end_game_run() -> void:
|
func end_game_run() -> void:
|
||||||
@@ -128,6 +142,158 @@ func is_game_capture_ready() -> bool:
|
|||||||
return _game_run_active and _game_ready and _ready_run_token == _game_run_token
|
return _game_run_active and _game_ready and _ready_run_token == _game_run_token
|
||||||
|
|
||||||
|
|
||||||
|
static func with_liveness_flags(status: Dictionary) -> Dictionary:
|
||||||
|
var enriched := status.duplicate(true)
|
||||||
|
var state := str(enriched.get("status", "stopped"))
|
||||||
|
enriched["helper_live"] = state == "live"
|
||||||
|
enriched["session_active"] = not state in ["not_live", "stopped"]
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary:
|
||||||
|
var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec
|
||||||
|
var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0))
|
||||||
|
var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0
|
||||||
|
## "stopped" also covers idle/never-ran; no game run is currently active.
|
||||||
|
var status := "stopped"
|
||||||
|
if _game_run_active:
|
||||||
|
if is_game_capture_ready():
|
||||||
|
status = "live"
|
||||||
|
elif not _game_helper_expected:
|
||||||
|
status = "no_helper"
|
||||||
|
elif elapsed_msec >= ready_wait_msec:
|
||||||
|
status = "not_live"
|
||||||
|
else:
|
||||||
|
status = "launching"
|
||||||
|
return with_liveness_flags({
|
||||||
|
"status": status,
|
||||||
|
"run_token": _game_run_token,
|
||||||
|
"active": _game_run_active,
|
||||||
|
"ready": is_game_capture_ready(),
|
||||||
|
"helper_expected": _game_helper_expected,
|
||||||
|
"run_started_msec": _game_run_started_msec,
|
||||||
|
"elapsed_msec": elapsed_msec,
|
||||||
|
"ready_wait_msec": ready_wait_msec,
|
||||||
|
"editor_log_cursor": _game_run_started_editor_cursor,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary:
|
||||||
|
var state := str(status.get("status", "stopped"))
|
||||||
|
var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||||
|
var recent_errors: Array = errors_info.get("errors", [])
|
||||||
|
var recent_errors_scope := str(errors_info.get("scope", "none"))
|
||||||
|
var truncated := bool(errors_info.get("truncated", false))
|
||||||
|
var data := {
|
||||||
|
"game_status": status.duplicate(true),
|
||||||
|
"recent_errors": recent_errors,
|
||||||
|
"recent_errors_scope": recent_errors_scope,
|
||||||
|
"recent_errors_may_predate_run": recent_errors_scope == "retained_recent",
|
||||||
|
"recent_errors_truncated": truncated,
|
||||||
|
}
|
||||||
|
var message := ""
|
||||||
|
match state:
|
||||||
|
"not_live":
|
||||||
|
if not recent_errors.is_empty() and recent_errors_scope == "run":
|
||||||
|
message = "The game failed to load or crashed before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||||
|
if truncated:
|
||||||
|
message += " Editor logs since this run may be truncated; showing retained errors."
|
||||||
|
elif not recent_errors.is_empty():
|
||||||
|
message = "The game is not responding and reported no load errors during this run. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||||
|
else:
|
||||||
|
message = "The game is not responding and reported no load errors before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and retry."
|
||||||
|
"no_helper":
|
||||||
|
message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game."
|
||||||
|
"launching":
|
||||||
|
message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0)
|
||||||
|
"stopped":
|
||||||
|
message = "The game is not running. Start the project and retry the game-side tool."
|
||||||
|
_:
|
||||||
|
message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state
|
||||||
|
var err := ErrorCodes.make(code, message)
|
||||||
|
var inner: Dictionary = err.get("error", {})
|
||||||
|
inner["data"] = data
|
||||||
|
err["error"] = inner
|
||||||
|
return err
|
||||||
|
|
||||||
|
|
||||||
|
func recent_editor_errors_since(cursor: int) -> Dictionary:
|
||||||
|
return _recent_editor_errors_since(cursor)
|
||||||
|
|
||||||
|
|
||||||
|
func _recent_editor_errors_since(cursor: int) -> Dictionary:
|
||||||
|
var out: Array[Dictionary] = []
|
||||||
|
var truncated := false
|
||||||
|
if _editor_log_buffer == null:
|
||||||
|
return {"errors": out, "truncated": false, "scope": "none"}
|
||||||
|
var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1)
|
||||||
|
truncated = bool(captured.get("truncated", false))
|
||||||
|
for raw_entry in captured.get("entries", []):
|
||||||
|
var compact := _compact_editor_error(raw_entry)
|
||||||
|
if compact.is_empty():
|
||||||
|
continue
|
||||||
|
out.append(compact)
|
||||||
|
if out.size() >= 5:
|
||||||
|
break
|
||||||
|
if not out.is_empty():
|
||||||
|
return {"errors": out, "truncated": truncated, "scope": "run"}
|
||||||
|
|
||||||
|
for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)):
|
||||||
|
var compact := _compact_editor_error(raw_entry, true)
|
||||||
|
if compact.is_empty():
|
||||||
|
continue
|
||||||
|
out.append(compact)
|
||||||
|
if out.size() >= 5:
|
||||||
|
break
|
||||||
|
if not out.is_empty():
|
||||||
|
return {"errors": out, "truncated": false, "scope": "retained_recent"}
|
||||||
|
return {"errors": out, "truncated": false, "scope": "none"}
|
||||||
|
|
||||||
|
|
||||||
|
func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary:
|
||||||
|
if not raw_entry is Dictionary:
|
||||||
|
return {}
|
||||||
|
var entry := raw_entry as Dictionary
|
||||||
|
if str(entry.get("level", "info")) != "error":
|
||||||
|
return {}
|
||||||
|
var path := str(entry.get("path", ""))
|
||||||
|
if fallback_recent and _is_diagnostic_noise_path(path):
|
||||||
|
return {}
|
||||||
|
var compact := {
|
||||||
|
"source": "editor",
|
||||||
|
"level": "error",
|
||||||
|
"text": str(entry.get("text", "")),
|
||||||
|
"path": path,
|
||||||
|
"line": int(entry.get("line", 0)),
|
||||||
|
"function": str(entry.get("function", "")),
|
||||||
|
}
|
||||||
|
if entry.has("details"):
|
||||||
|
compact["details"] = entry["details"].duplicate(true)
|
||||||
|
return compact
|
||||||
|
|
||||||
|
|
||||||
|
func _is_diagnostic_noise_path(path: String) -> bool:
|
||||||
|
return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/")
|
||||||
|
|
||||||
|
|
||||||
|
func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]:
|
||||||
|
var out: Array[Dictionary] = []
|
||||||
|
for i in range(entries.size() - 1, -1, -1):
|
||||||
|
out.append(entries[i])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _format_editor_error_summary(entry: Dictionary) -> String:
|
||||||
|
var text := str(entry.get("text", "editor error"))
|
||||||
|
var path := str(entry.get("path", ""))
|
||||||
|
var line := int(entry.get("line", 0))
|
||||||
|
if not path.is_empty() and line > 0:
|
||||||
|
return "%s (%s:%d)" % [text, path, line]
|
||||||
|
if not path.is_empty():
|
||||||
|
return "%s (%s)" % [text, path]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
func _capture(message: String, data: Array, session_id: int) -> bool:
|
func _capture(message: String, data: Array, session_id: int) -> bool:
|
||||||
## Godot passes the full "prefix:tail" string as `message`.
|
## Godot passes the full "prefix:tail" string as `message`.
|
||||||
match message:
|
match message:
|
||||||
@@ -152,18 +318,15 @@ func _capture(message: String, data: Array, session_id: int) -> bool:
|
|||||||
## Boot beacon from the game-side autoload. Tells us the
|
## Boot beacon from the game-side autoload. Tells us the
|
||||||
## game has registered its "mcp" capture and is safe to send
|
## game has registered its "mcp" capture and is safe to send
|
||||||
## take_screenshot to — before this, Godot's debugger would
|
## take_screenshot to — before this, Godot's debugger would
|
||||||
## drop our message silently. Also marks a fresh play
|
## drop our message silently.
|
||||||
## cycle: rotate the game-log buffer so each run starts
|
|
||||||
## clean and gets a new run_id.
|
|
||||||
_game_ready = true
|
_game_ready = true
|
||||||
_ready_run_token = _game_run_token
|
_ready_run_token = _game_run_token
|
||||||
game_ready.emit()
|
game_ready.emit()
|
||||||
if _game_log_buffer:
|
if _log_buffer:
|
||||||
var run_id := _game_log_buffer.clear_for_new_run()
|
if _game_log_buffer:
|
||||||
if _log_buffer:
|
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id())
|
||||||
_log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % run_id)
|
else:
|
||||||
elif _log_buffer:
|
_log_buffer.log("[debug] <- mcp:hello from game_helper")
|
||||||
_log_buffer.log("[debug] <- mcp:hello from game_helper")
|
|
||||||
return true
|
return true
|
||||||
"mcp:eval_response":
|
"mcp:eval_response":
|
||||||
_on_eval_response(data)
|
_on_eval_response(data)
|
||||||
@@ -262,8 +425,8 @@ func _wait_then_send(
|
|||||||
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
||||||
await tree.process_frame
|
await tree.process_frame
|
||||||
if not is_game_capture_ready():
|
if not is_game_capture_ready():
|
||||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
_send_error_response(connection, request_id,
|
||||||
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Check Project Settings → Autoload for _mcp_game_helper." % int(GAME_READY_WAIT_SEC))
|
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
|
||||||
return
|
return
|
||||||
_send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec)
|
_send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec)
|
||||||
|
|
||||||
@@ -357,16 +520,23 @@ func _on_timeout(request_id: String) -> void:
|
|||||||
var connection: McpConnection = pending.connection
|
var connection: McpConnection = pending.connection
|
||||||
if connection == null or not is_instance_valid(connection):
|
if connection == null or not is_instance_valid(connection):
|
||||||
return
|
return
|
||||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
var status := get_game_status(-1, GAME_READY_WAIT_SEC)
|
||||||
"Game screenshot timed out. The running game must include the _mcp_game_helper autoload (added automatically when the plugin is enabled — check Project Settings → Autoload). If the autoload is missing, re-enable the plugin and relaunch the game. For headless or custom-main-loop builds, use source='viewport' instead.")
|
var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR,
|
||||||
|
"Game screenshot timed out after reaching the game helper. The game may be busy or unable to render a frame. Check logs_read(source='game') and retry.")
|
||||||
|
if status.get("status", "") != "live":
|
||||||
|
err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR)
|
||||||
|
_send_error_response(connection, request_id, err)
|
||||||
if _log_buffer:
|
if _log_buffer:
|
||||||
_log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id)
|
_log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id)
|
||||||
|
|
||||||
|
|
||||||
func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void:
|
func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void:
|
||||||
|
_send_error_response(connection, request_id, ErrorCodes.make(code, message))
|
||||||
|
|
||||||
|
|
||||||
|
func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void:
|
||||||
if connection == null or not is_instance_valid(connection):
|
if connection == null or not is_instance_valid(connection):
|
||||||
return
|
return
|
||||||
var err := ErrorCodes.make(code, message)
|
|
||||||
connection.send_deferred_response(request_id, err)
|
connection.send_deferred_response(request_id, err)
|
||||||
|
|
||||||
|
|
||||||
@@ -447,8 +617,8 @@ func _wait_then_eval(
|
|||||||
## but the game-side capture didn't register within the short wait. Fast
|
## but the game-side capture didn't register within the short wait. Fast
|
||||||
## and caller-actionable; classifying it apart from the opaque 10s hang
|
## and caller-actionable; classifying it apart from the opaque 10s hang
|
||||||
## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung".
|
## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung".
|
||||||
_send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY,
|
_send_error_response(connection, request_id,
|
||||||
"Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC))
|
_explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY))
|
||||||
return
|
return
|
||||||
_send_eval(tree, code, request_id, connection, timeout_sec)
|
_send_eval(tree, code, request_id, connection, timeout_sec)
|
||||||
|
|
||||||
@@ -704,8 +874,8 @@ func _wait_then_game_command(
|
|||||||
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
while not is_game_capture_ready() and Time.get_ticks_msec() < deadline:
|
||||||
await tree.process_frame
|
await tree.process_frame
|
||||||
if not is_game_capture_ready():
|
if not is_game_capture_ready():
|
||||||
_send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR,
|
_send_error_response(connection, request_id,
|
||||||
"Game-side autoload never registered its debugger capture within %ds. Is the game actually running?" % int(GAME_READY_WAIT_SEC))
|
_explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR))
|
||||||
return
|
return
|
||||||
_send_game_command(tree, op, params, request_id, connection, timeout_sec)
|
_send_game_command(tree, op, params, request_id, connection, timeout_sec)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
|
|||||||
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
|
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
|
||||||
"create_script": 4500,
|
"create_script": 4500,
|
||||||
"stop_project": 4500,
|
"stop_project": 4500,
|
||||||
|
"run_project": 6000,
|
||||||
"take_screenshot": 30000,
|
"take_screenshot": 30000,
|
||||||
"game_eval": 15000,
|
"game_eval": 15000,
|
||||||
"game_command": 15000,
|
"game_command": 15000,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_
|
|||||||
|
|
||||||
func get_editor_state(_params: Dictionary) -> Dictionary:
|
func get_editor_state(_params: Dictionary) -> Dictionary:
|
||||||
var scene_root := EditorInterface.get_edited_scene_root()
|
var scene_root := EditorInterface.get_edited_scene_root()
|
||||||
|
var game_status := _current_game_status()
|
||||||
var data := {
|
var data := {
|
||||||
"godot_version": Engine.get_version_info().get("string", "unknown"),
|
"godot_version": Engine.get_version_info().get("string", "unknown"),
|
||||||
"project_name": ProjectSettings.get_setting("application/config/name", ""),
|
"project_name": ProjectSettings.get_setting("application/config/name", ""),
|
||||||
@@ -38,6 +39,9 @@ func get_editor_state(_params: Dictionary) -> Dictionary:
|
|||||||
## false between Play→Stop cycles. Lets capture-source=game callers
|
## false between Play→Stop cycles. Lets capture-source=game callers
|
||||||
## poll for a real ready signal instead of guessing with sleep().
|
## poll for a real ready signal instead of guessing with sleep().
|
||||||
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
|
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
|
||||||
|
"game_status": game_status,
|
||||||
|
"helper_live": bool(game_status.get("helper_live", false)),
|
||||||
|
"session_active": bool(game_status.get("session_active", false)),
|
||||||
}
|
}
|
||||||
## Half-installed addon tree from a failed self-update rollback. When
|
## Half-installed addon tree from a failed self-update rollback. When
|
||||||
## non-empty, the agent / dock paint the operator-facing recovery copy
|
## non-empty, the agent / dock paint the operator-facing recovery copy
|
||||||
@@ -72,6 +76,7 @@ func get_logs(params: Dictionary) -> Dictionary:
|
|||||||
var include_details: bool = bool(params.get("include_details", false))
|
var include_details: bool = bool(params.get("include_details", false))
|
||||||
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
|
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
|
||||||
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
|
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
|
||||||
|
var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", ""))
|
||||||
if not source in VALID_LOG_SOURCES:
|
if not source in VALID_LOG_SOURCES:
|
||||||
return ErrorCodes.make(
|
return ErrorCodes.make(
|
||||||
ErrorCodes.VALUE_OUT_OF_RANGE,
|
ErrorCodes.VALUE_OUT_OF_RANGE,
|
||||||
@@ -82,7 +87,7 @@ func get_logs(params: Dictionary) -> Dictionary:
|
|||||||
"plugin":
|
"plugin":
|
||||||
return _get_plugin_logs(count, offset)
|
return _get_plugin_logs(count, offset)
|
||||||
"game":
|
"game":
|
||||||
return _get_game_logs(count, offset, include_details)
|
return _get_game_logs(count, offset, include_details, since_run_id)
|
||||||
"editor":
|
"editor":
|
||||||
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
|
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
|
||||||
"all":
|
"all":
|
||||||
@@ -90,6 +95,17 @@ func get_logs(params: Dictionary) -> Dictionary:
|
|||||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
func _current_game_status() -> Dictionary:
|
||||||
|
if _debugger_plugin == null:
|
||||||
|
return McpDebuggerPlugin.with_liveness_flags({
|
||||||
|
"status": "stopped",
|
||||||
|
"active": false,
|
||||||
|
"ready": false,
|
||||||
|
"helper_expected": true,
|
||||||
|
})
|
||||||
|
return _debugger_plugin.get_game_status()
|
||||||
|
|
||||||
|
|
||||||
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
|
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
|
||||||
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
|
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
|
||||||
var page: Array[Dictionary] = []
|
var page: Array[Dictionary] = []
|
||||||
@@ -107,7 +123,10 @@ func _get_plugin_logs(count: int, offset: int) -> Dictionary:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionary:
|
func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
|
||||||
|
var game_status := _current_game_status()
|
||||||
|
var helper_live := bool(game_status.get("helper_live", false))
|
||||||
|
var session_active := bool(game_status.get("session_active", false))
|
||||||
if _game_log_buffer == null:
|
if _game_log_buffer == null:
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
@@ -117,21 +136,35 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
|
|||||||
"returned_count": 0,
|
"returned_count": 0,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
"run_id": "",
|
"run_id": "",
|
||||||
"is_running": false,
|
"current_run_id": "",
|
||||||
|
"is_running": session_active,
|
||||||
|
"helper_live": helper_live,
|
||||||
|
"session_active": session_active,
|
||||||
|
"game_status": game_status,
|
||||||
"dropped_count": 0,
|
"dropped_count": 0,
|
||||||
|
"stale_run_id": false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
|
var current_run_id := _game_log_buffer.run_id()
|
||||||
|
var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id
|
||||||
|
var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id
|
||||||
|
var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count)
|
||||||
|
var page := _entries_for_response(run_page.get("entries", []), include_details)
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"source": "game",
|
"source": "game",
|
||||||
"lines": page,
|
"lines": page,
|
||||||
"total_count": _game_log_buffer.total_count(),
|
"total_count": int(run_page.get("total_count", 0)),
|
||||||
"returned_count": page.size(),
|
"returned_count": page.size(),
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
"run_id": _game_log_buffer.run_id(),
|
"run_id": target_run_id,
|
||||||
"is_running": EditorInterface.is_playing_scene(),
|
"current_run_id": current_run_id,
|
||||||
|
"is_running": session_active,
|
||||||
|
"helper_live": helper_live,
|
||||||
|
"session_active": session_active,
|
||||||
|
"game_status": game_status,
|
||||||
"dropped_count": _game_log_buffer.dropped_count(),
|
"dropped_count": _game_log_buffer.dropped_count(),
|
||||||
|
"stale_run_id": stale_run_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,21 +244,24 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
|
|||||||
combined.append({"source": "plugin", "level": "info", "text": line})
|
combined.append({"source": "plugin", "level": "info", "text": line})
|
||||||
for entry in _collect_editor_log_entries():
|
for entry in _collect_editor_log_entries():
|
||||||
combined.append(entry)
|
combined.append(entry)
|
||||||
|
var run_id := ""
|
||||||
|
var current_run_id := ""
|
||||||
|
var dropped := 0
|
||||||
if _game_log_buffer != null:
|
if _game_log_buffer != null:
|
||||||
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
|
run_id = _game_log_buffer.run_id()
|
||||||
|
current_run_id = run_id
|
||||||
|
dropped = _game_log_buffer.dropped_count()
|
||||||
|
var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES)
|
||||||
|
for entry in run_page.get("entries", []):
|
||||||
combined.append(entry)
|
combined.append(entry)
|
||||||
var stop := mini(combined.size(), offset + count)
|
var stop := mini(combined.size(), offset + count)
|
||||||
var page: Array[Dictionary] = []
|
var page: Array[Dictionary] = []
|
||||||
for i in range(mini(offset, combined.size()), stop):
|
for i in range(mini(offset, combined.size()), stop):
|
||||||
page.append(combined[i])
|
page.append(combined[i])
|
||||||
page = _entries_for_response(page, include_details)
|
page = _entries_for_response(page, include_details)
|
||||||
var run_id := ""
|
|
||||||
var dropped := 0
|
|
||||||
if _game_log_buffer != null:
|
|
||||||
run_id = _game_log_buffer.run_id()
|
|
||||||
dropped = _game_log_buffer.dropped_count()
|
|
||||||
if _editor_log_buffer != null:
|
if _editor_log_buffer != null:
|
||||||
dropped += _editor_log_buffer.dropped_count()
|
dropped += _editor_log_buffer.dropped_count()
|
||||||
|
var game_status := _current_game_status()
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"source": "all",
|
"source": "all",
|
||||||
@@ -234,7 +270,11 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
|
|||||||
"returned_count": page.size(),
|
"returned_count": page.size(),
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"is_running": EditorInterface.is_playing_scene(),
|
"current_run_id": current_run_id,
|
||||||
|
"is_running": bool(game_status.get("session_active", false)),
|
||||||
|
"helper_live": bool(game_status.get("helper_live", false)),
|
||||||
|
"session_active": bool(game_status.get("session_active", false)),
|
||||||
|
"game_status": game_status,
|
||||||
"dropped_count": dropped,
|
"dropped_count": dropped,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,16 +239,10 @@ func set_property(params: Dictionary) -> Dictionary:
|
|||||||
# properties. Mirrors resource_create's inline-assign path but
|
# properties. Mirrors resource_create's inline-assign path but
|
||||||
# avoids a separate tool call for the common case.
|
# avoids a separate tool call for the common case.
|
||||||
var type_str: String = value.get("__class__", "")
|
var type_str: String = value.get("__class__", "")
|
||||||
var class_err := ResourceHandler._validate_resource_class(type_str)
|
var made := ResourceHandler._instantiate_resource(type_str)
|
||||||
if class_err != null:
|
if made is Dictionary:
|
||||||
return class_err
|
return made
|
||||||
var instance := ClassDB.instantiate(type_str)
|
var res: Resource = made
|
||||||
if instance == null or not (instance is Resource):
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.INTERNAL_ERROR,
|
|
||||||
"Failed to instantiate %s as a Resource" % type_str
|
|
||||||
)
|
|
||||||
var res: Resource = instance
|
|
||||||
var remaining: Dictionary = (value as Dictionary).duplicate()
|
var remaining: Dictionary = (value as Dictionary).duplicate()
|
||||||
remaining.erase("__class__")
|
remaining.erase("__class__")
|
||||||
if not remaining.is_empty():
|
if not remaining.is_empty():
|
||||||
@@ -528,6 +522,7 @@ func _set_owner_recursive(node: Node, owner: Node) -> void:
|
|||||||
## is optional — the coercer defaults it to 1.0 when absent.
|
## is optional — the coercer defaults it to 1.0 when absent.
|
||||||
const VECTOR2_KEYS: Array[String] = ["x", "y"]
|
const VECTOR2_KEYS: Array[String] = ["x", "y"]
|
||||||
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
|
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
|
||||||
|
const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
|
||||||
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
|
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
|
||||||
|
|
||||||
|
|
||||||
@@ -556,6 +551,8 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
|
|||||||
ok = value is PackedVector2Array
|
ok = value is PackedVector2Array
|
||||||
TYPE_PACKED_VECTOR3_ARRAY:
|
TYPE_PACKED_VECTOR3_ARRAY:
|
||||||
ok = value is PackedVector3Array
|
ok = value is PackedVector3Array
|
||||||
|
TYPE_PACKED_VECTOR4_ARRAY:
|
||||||
|
ok = value is PackedVector4Array
|
||||||
TYPE_PACKED_COLOR_ARRAY:
|
TYPE_PACKED_COLOR_ARRAY:
|
||||||
ok = value is PackedColorArray
|
ok = value is PackedColorArray
|
||||||
TYPE_PACKED_INT32_ARRAY:
|
TYPE_PACKED_INT32_ARRAY:
|
||||||
@@ -568,8 +565,31 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
|
|||||||
ok = value is PackedFloat64Array
|
ok = value is PackedFloat64Array
|
||||||
TYPE_PACKED_STRING_ARRAY:
|
TYPE_PACKED_STRING_ARRAY:
|
||||||
ok = value is PackedStringArray
|
ok = value is PackedStringArray
|
||||||
|
TYPE_VECTOR2I: ok = value is Vector2i
|
||||||
|
TYPE_VECTOR3I: ok = value is Vector3i
|
||||||
|
TYPE_VECTOR4: ok = value is Vector4
|
||||||
|
TYPE_VECTOR4I: ok = value is Vector4i
|
||||||
|
TYPE_QUATERNION: ok = value is Quaternion
|
||||||
|
TYPE_RECT2: ok = value is Rect2
|
||||||
|
TYPE_RECT2I: ok = value is Rect2i
|
||||||
|
TYPE_AABB: ok = value is AABB
|
||||||
|
TYPE_PLANE: ok = value is Plane
|
||||||
|
TYPE_BASIS: ok = value is Basis
|
||||||
|
TYPE_TRANSFORM2D: ok = value is Transform2D
|
||||||
|
TYPE_TRANSFORM3D: ok = value is Transform3D
|
||||||
|
TYPE_PROJECTION: ok = value is Projection
|
||||||
_:
|
_:
|
||||||
return null
|
# null / untyped-TYPE_NIL / already-correct-type are handled by
|
||||||
|
# Godot's setter; anything else would silently no-op, so error.
|
||||||
|
if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
|
||||||
|
return null
|
||||||
|
var unsupported := ErrorCodes.make(
|
||||||
|
ErrorCodes.WRONG_TYPE,
|
||||||
|
"Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
|
||||||
|
type_string(typeof(value)), type_string(target_type),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return ErrorCodes.prefix_message(unsupported, prefix)
|
||||||
if ok:
|
if ok:
|
||||||
return null
|
return null
|
||||||
var dict_err := _check_dict_coerce_failed(value, target_type)
|
var dict_err := _check_dict_coerce_failed(value, target_type)
|
||||||
@@ -597,6 +617,8 @@ static func _shape_hint(target_type: int) -> String:
|
|||||||
return "[{\"x\":0,\"y\":0}, ...]"
|
return "[{\"x\":0,\"y\":0}, ...]"
|
||||||
TYPE_PACKED_VECTOR3_ARRAY:
|
TYPE_PACKED_VECTOR3_ARRAY:
|
||||||
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
|
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
|
||||||
|
TYPE_PACKED_VECTOR4_ARRAY:
|
||||||
|
return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]"
|
||||||
TYPE_PACKED_COLOR_ARRAY:
|
TYPE_PACKED_COLOR_ARRAY:
|
||||||
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
|
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
|
||||||
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
|
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
|
||||||
@@ -605,6 +627,24 @@ static func _shape_hint(target_type: int) -> String:
|
|||||||
return "[float, ...]"
|
return "[float, ...]"
|
||||||
TYPE_PACKED_STRING_ARRAY:
|
TYPE_PACKED_STRING_ARRAY:
|
||||||
return "[\"...\", ...]"
|
return "[\"...\", ...]"
|
||||||
|
TYPE_VECTOR2I:
|
||||||
|
return "{\"x\":0,\"y\":0}"
|
||||||
|
TYPE_VECTOR3I:
|
||||||
|
return "{\"x\":0,\"y\":0,\"z\":0}"
|
||||||
|
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
|
||||||
|
return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
|
||||||
|
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
|
||||||
|
return "{\"position\":{...},\"size\":{...}}"
|
||||||
|
TYPE_PLANE:
|
||||||
|
return "{\"normal\":{...},\"d\":0}"
|
||||||
|
TYPE_BASIS:
|
||||||
|
return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
|
||||||
|
TYPE_TRANSFORM2D:
|
||||||
|
return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
|
||||||
|
TYPE_TRANSFORM3D:
|
||||||
|
return "{\"basis\":{...},\"origin\":{...}}"
|
||||||
|
TYPE_PROJECTION:
|
||||||
|
return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
|
||||||
var keys: Array[String] = []
|
var keys: Array[String] = []
|
||||||
match target_type:
|
match target_type:
|
||||||
TYPE_VECTOR2: keys = VECTOR2_KEYS
|
TYPE_VECTOR2: keys = VECTOR2_KEYS
|
||||||
@@ -717,6 +757,17 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
|
|||||||
else:
|
else:
|
||||||
return value
|
return value
|
||||||
return out
|
return out
|
||||||
|
TYPE_PACKED_VECTOR4_ARRAY:
|
||||||
|
if value is Array:
|
||||||
|
var out := PackedVector4Array()
|
||||||
|
for item in value:
|
||||||
|
if item is Vector4:
|
||||||
|
out.append(item)
|
||||||
|
elif item is Dictionary and item.has_all(VECTOR4_KEYS):
|
||||||
|
out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
return out
|
||||||
TYPE_PACKED_COLOR_ARRAY:
|
TYPE_PACKED_COLOR_ARRAY:
|
||||||
if value is Array:
|
if value is Array:
|
||||||
var out := PackedColorArray()
|
var out := PackedColorArray()
|
||||||
@@ -757,6 +808,72 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
|
|||||||
else:
|
else:
|
||||||
return value
|
return value
|
||||||
return out
|
return out
|
||||||
|
TYPE_VECTOR2I:
|
||||||
|
if value is Dictionary and value.has_all(VECTOR2_KEYS):
|
||||||
|
return Vector2i(int(value["x"]), int(value["y"]))
|
||||||
|
TYPE_VECTOR3I:
|
||||||
|
if value is Dictionary and value.has_all(VECTOR3_KEYS):
|
||||||
|
return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
|
||||||
|
TYPE_VECTOR4:
|
||||||
|
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||||
|
return Vector4(value["x"], value["y"], value["z"], value["w"])
|
||||||
|
TYPE_VECTOR4I:
|
||||||
|
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||||
|
return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
|
||||||
|
TYPE_QUATERNION:
|
||||||
|
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||||
|
return Quaternion(value["x"], value["y"], value["z"], value["w"])
|
||||||
|
TYPE_RECT2:
|
||||||
|
if value is Dictionary and value.has("position") and value.has("size"):
|
||||||
|
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
|
||||||
|
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
|
||||||
|
if p is Vector2 and s is Vector2:
|
||||||
|
return Rect2(p, s)
|
||||||
|
TYPE_RECT2I:
|
||||||
|
if value is Dictionary and value.has("position") and value.has("size"):
|
||||||
|
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
|
||||||
|
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
|
||||||
|
if p is Vector2i and s is Vector2i:
|
||||||
|
return Rect2i(p, s)
|
||||||
|
TYPE_AABB:
|
||||||
|
if value is Dictionary and value.has("position") and value.has("size"):
|
||||||
|
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
|
||||||
|
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
|
||||||
|
if p is Vector3 and s is Vector3:
|
||||||
|
return AABB(p, s)
|
||||||
|
TYPE_PLANE:
|
||||||
|
if value is Dictionary and value.has("normal") and value.has("d"):
|
||||||
|
var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
|
||||||
|
if n is Vector3:
|
||||||
|
return Plane(n, float(value["d"]))
|
||||||
|
TYPE_BASIS:
|
||||||
|
if value is Dictionary and value.has_all(["x", "y", "z"]):
|
||||||
|
var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
|
||||||
|
var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
|
||||||
|
var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
|
||||||
|
if bx is Vector3 and by is Vector3 and bz is Vector3:
|
||||||
|
return Basis(bx, by, bz)
|
||||||
|
TYPE_TRANSFORM2D:
|
||||||
|
if value is Dictionary and value.has_all(["x", "y", "origin"]):
|
||||||
|
var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
|
||||||
|
var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
|
||||||
|
var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
|
||||||
|
if tx is Vector2 and ty is Vector2 and to_ is Vector2:
|
||||||
|
return Transform2D(tx, ty, to_)
|
||||||
|
TYPE_TRANSFORM3D:
|
||||||
|
if value is Dictionary and value.has("basis") and value.has("origin"):
|
||||||
|
var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
|
||||||
|
var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
|
||||||
|
if b is Basis and o is Vector3:
|
||||||
|
return Transform3D(b, o)
|
||||||
|
TYPE_PROJECTION:
|
||||||
|
if value is Dictionary and value.has_all(VECTOR4_KEYS):
|
||||||
|
var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
|
||||||
|
var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
|
||||||
|
var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
|
||||||
|
var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
|
||||||
|
if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
|
||||||
|
return Projection(px, py, pz, pw)
|
||||||
# PackedByteArray intentionally unhandled — needs design decision
|
# PackedByteArray intentionally unhandled — needs design decision
|
||||||
# (base64 string vs. raw int list); JSON has no native byte type.
|
# (base64 string vs. raw int list); JSON has no native byte type.
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
|
|||||||
## Handles project settings and filesystem search commands.
|
## Handles project settings and filesystem search commands.
|
||||||
|
|
||||||
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
|
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
|
||||||
|
const RUN_READY_WAIT_SEC := 3.0
|
||||||
|
|
||||||
var _connection: McpConnection
|
var _connection: McpConnection
|
||||||
var _debugger_plugin
|
var _debugger_plugin
|
||||||
|
var _editor_log_buffer
|
||||||
|
|
||||||
|
|
||||||
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
|
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
|
||||||
_connection = connection
|
_connection = connection
|
||||||
_debugger_plugin = debugger_plugin
|
_debugger_plugin = debugger_plugin
|
||||||
|
_editor_log_buffer = editor_log_buffer
|
||||||
|
|
||||||
|
|
||||||
func get_project_setting(params: Dictionary) -> Dictionary:
|
func get_project_setting(params: Dictionary) -> Dictionary:
|
||||||
@@ -81,16 +84,15 @@ func run_project(params: Dictionary) -> Dictionary:
|
|||||||
# stop-not-running case in telemetry). Surface state via was_already_running
|
# stop-not-running case in telemetry). Surface state via was_already_running
|
||||||
# so a caller wanting a *different* scene can detect and stop+restart.
|
# so a caller wanting a *different* scene can detect and stop+restart.
|
||||||
if EditorInterface.is_playing_scene():
|
if EditorInterface.is_playing_scene():
|
||||||
return {
|
return _run_project_current_liveness_response(
|
||||||
"data": {
|
_run_project_base_data(
|
||||||
"mode": mode,
|
mode,
|
||||||
"scene": params.get("scene", ""),
|
str(params.get("scene", "")),
|
||||||
"autosave": autosave,
|
autosave,
|
||||||
"was_already_running": true,
|
true,
|
||||||
"undoable": false,
|
"Project was already running; no action taken"
|
||||||
"reason": "Project was already running; no action taken",
|
)
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
var validation_error: Variant = null
|
var validation_error: Variant = null
|
||||||
if mode == "custom":
|
if mode == "custom":
|
||||||
@@ -125,7 +127,7 @@ func run_project(params: Dictionary) -> Dictionary:
|
|||||||
restore_setting = true
|
restore_setting = true
|
||||||
|
|
||||||
if _debugger_plugin != null:
|
if _debugger_plugin != null:
|
||||||
_debugger_plugin.begin_game_run()
|
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
|
||||||
|
|
||||||
match mode:
|
match mode:
|
||||||
"main":
|
"main":
|
||||||
@@ -142,18 +144,173 @@ func run_project(params: Dictionary) -> Dictionary:
|
|||||||
if _connection:
|
if _connection:
|
||||||
_connection.pause_processing = false
|
_connection.pause_processing = false
|
||||||
|
|
||||||
|
var base_data := _run_project_base_data(
|
||||||
|
mode,
|
||||||
|
str(params.get("scene", "")),
|
||||||
|
autosave,
|
||||||
|
false,
|
||||||
|
"Play/stop is a runtime action"
|
||||||
|
)
|
||||||
|
var request_id: String = params.get("_request_id", "")
|
||||||
|
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
|
||||||
|
_finish_run_project_deferred(request_id, base_data)
|
||||||
|
return McpDispatcher.DEFERRED_RESPONSE
|
||||||
|
|
||||||
|
return _run_project_current_liveness_response(base_data)
|
||||||
|
|
||||||
|
|
||||||
|
func _editor_log_cursor() -> int:
|
||||||
|
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
|
||||||
|
|
||||||
|
|
||||||
|
func _game_helper_autoload_expected() -> bool:
|
||||||
|
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
|
||||||
|
|
||||||
|
|
||||||
|
func _run_project_base_data(
|
||||||
|
mode: String,
|
||||||
|
scene: String,
|
||||||
|
autosave: bool,
|
||||||
|
was_already_running: bool,
|
||||||
|
reason: String
|
||||||
|
) -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"data": {
|
"mode": mode,
|
||||||
"mode": mode,
|
"scene": scene,
|
||||||
"scene": params.get("scene", ""),
|
"autosave": autosave,
|
||||||
"autosave": autosave,
|
"was_already_running": was_already_running,
|
||||||
"was_already_running": false,
|
"undoable": false,
|
||||||
"undoable": false,
|
"reason": reason,
|
||||||
"reason": "Play/stop is a runtime action",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
|
||||||
|
if _debugger_plugin == null:
|
||||||
|
return {"data": base_data}
|
||||||
|
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||||
|
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||||
|
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
|
||||||
|
|
||||||
|
|
||||||
|
func _finish_run_project_deferred(request_id: String, base_data: Dictionary) -> void:
|
||||||
|
var tree := _connection.get_tree()
|
||||||
|
while true:
|
||||||
|
await tree.process_frame
|
||||||
|
if not is_instance_valid(_connection):
|
||||||
|
return
|
||||||
|
var pre_status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||||
|
if (
|
||||||
|
not EditorInterface.is_playing_scene()
|
||||||
|
and int(pre_status.get("elapsed_msec", 0)) > 100
|
||||||
|
and str(pre_status.get("status", "stopped")) == "launching"
|
||||||
|
):
|
||||||
|
_debugger_plugin.end_game_run()
|
||||||
|
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
|
||||||
|
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
|
||||||
|
var decision := _run_project_liveness_decision(status, errors_info)
|
||||||
|
if not bool(decision.get("resolve", false)):
|
||||||
|
continue
|
||||||
|
_connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
|
||||||
|
var data := base_data.duplicate(true)
|
||||||
|
var game_status: Dictionary = decision.get("game_status", {})
|
||||||
|
data["game_status"] = game_status
|
||||||
|
data["helper_live"] = bool(game_status.get("helper_live", false))
|
||||||
|
data["session_active"] = bool(game_status.get("session_active", false))
|
||||||
|
if bool(data.get("was_already_running", false)):
|
||||||
|
data["reason"] = _run_project_already_running_message(decision)
|
||||||
|
else:
|
||||||
|
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
|
||||||
|
data["recent_errors"] = decision.get("recent_errors", [])
|
||||||
|
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
|
||||||
|
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
|
||||||
|
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
|
||||||
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
func _run_project_already_running_message(decision: Dictionary) -> String:
|
||||||
|
var state := str(decision.get("liveness_status", "unknown"))
|
||||||
|
match state:
|
||||||
|
"live":
|
||||||
|
return "Project was already running; the Godot AI game helper is live."
|
||||||
|
"not_live":
|
||||||
|
var errors: Array = decision.get("recent_errors", [])
|
||||||
|
var scope := str(decision.get("recent_errors_scope", "none"))
|
||||||
|
if not errors.is_empty() and scope == "run":
|
||||||
|
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
||||||
|
if not errors.is_empty():
|
||||||
|
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
|
||||||
|
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
|
||||||
|
"no_helper":
|
||||||
|
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
|
||||||
|
"launching":
|
||||||
|
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
|
||||||
|
"stopped":
|
||||||
|
return "Project was already marked playing by the editor, but no active game liveness run exists."
|
||||||
|
_:
|
||||||
|
return "Project was already running; current liveness status is %s." % state
|
||||||
|
|
||||||
|
|
||||||
|
func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
|
||||||
|
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
|
||||||
|
var state := str(status.get("status", "stopped"))
|
||||||
|
var recent_errors: Array = errors_info.get("errors", [])
|
||||||
|
var errors_scope := str(errors_info.get("scope", "none"))
|
||||||
|
var truncated := bool(errors_info.get("truncated", false))
|
||||||
|
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
|
||||||
|
var elapsed_msec := int(status.get("elapsed_msec", 0))
|
||||||
|
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
|
||||||
|
var decision := {
|
||||||
|
"resolve": false,
|
||||||
|
"game_status": enriched_status,
|
||||||
|
"liveness_status": state,
|
||||||
|
"recent_errors": recent_errors,
|
||||||
|
"recent_errors_scope": errors_scope,
|
||||||
|
"recent_errors_may_predate_run": errors_scope == "retained_recent",
|
||||||
|
"recent_errors_truncated": truncated,
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
if state == "live":
|
||||||
|
decision["resolve"] = true
|
||||||
|
decision["message"] = "Game launched and the Godot AI game helper is live."
|
||||||
|
elif correlated_error:
|
||||||
|
decision["resolve"] = true
|
||||||
|
decision["liveness_status"] = "not_live"
|
||||||
|
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||||
|
if truncated:
|
||||||
|
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
|
||||||
|
elif state == "not_live":
|
||||||
|
decision["resolve"] = true
|
||||||
|
if not recent_errors.is_empty():
|
||||||
|
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
|
||||||
|
else:
|
||||||
|
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
|
||||||
|
elif state == "no_helper":
|
||||||
|
decision["resolve"] = true
|
||||||
|
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
|
||||||
|
elif state == "stopped":
|
||||||
|
decision["resolve"] = true
|
||||||
|
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
|
||||||
|
elif state == "launching" and elapsed_msec >= ready_wait_msec:
|
||||||
|
decision["resolve"] = true
|
||||||
|
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
|
||||||
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
func _format_editor_error_summary(entry: Dictionary) -> String:
|
||||||
|
var text := str(entry.get("text", "editor error"))
|
||||||
|
var path := str(entry.get("path", ""))
|
||||||
|
var line := int(entry.get("line", 0))
|
||||||
|
if not path.is_empty() and line > 0:
|
||||||
|
return "%s (%s:%d)" % [text, path, line]
|
||||||
|
if not path.is_empty():
|
||||||
|
return "%s (%s)" % [text, path]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
func stop_project(params: Dictionary) -> Dictionary:
|
func stop_project(params: Dictionary) -> Dictionary:
|
||||||
# Idempotent: a project that's already stopped satisfies the caller's intent.
|
# Idempotent: a project that's already stopped satisfies the caller's intent.
|
||||||
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
|
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
|
||||||
|
|||||||
@@ -178,19 +178,10 @@ func create_resource(params: Dictionary) -> Dictionary:
|
|||||||
return home_err
|
return home_err
|
||||||
var has_file_target := not resource_path.is_empty()
|
var has_file_target := not resource_path.is_empty()
|
||||||
|
|
||||||
var class_err := _validate_resource_class(type_str)
|
var made := _instantiate_resource(type_str)
|
||||||
if class_err != null:
|
if made is Dictionary:
|
||||||
return class_err
|
return made
|
||||||
|
var res: Resource = made
|
||||||
var instance := ClassDB.instantiate(type_str)
|
|
||||||
if instance == null:
|
|
||||||
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
|
|
||||||
if not (instance is Resource):
|
|
||||||
return ErrorCodes.make(
|
|
||||||
ErrorCodes.INTERNAL_ERROR,
|
|
||||||
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
|
|
||||||
)
|
|
||||||
var res: Resource = instance
|
|
||||||
|
|
||||||
if not properties.is_empty():
|
if not properties.is_empty():
|
||||||
var apply_err := _apply_resource_properties(res, properties)
|
var apply_err := _apply_resource_properties(res, properties)
|
||||||
@@ -226,6 +217,56 @@ static func _validate_resource_class(type_str: String) -> Variant:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Resolve a resource type name to a fresh instance. Handles engine built-ins
|
||||||
|
## (ClassDB) and project `class_name` Resources (the global script-class
|
||||||
|
## registry). Returns a Resource on success, or an error dict on failure.
|
||||||
|
static func _instantiate_resource(type_str: String) -> Variant:
|
||||||
|
if ClassDB.class_exists(type_str):
|
||||||
|
var class_err: Variant = _validate_resource_class(type_str)
|
||||||
|
if class_err != null:
|
||||||
|
return class_err
|
||||||
|
var built_in := ClassDB.instantiate(type_str)
|
||||||
|
if built_in == null or not (built_in is Resource):
|
||||||
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
|
||||||
|
return built_in
|
||||||
|
for entry in ProjectSettings.get_global_class_list():
|
||||||
|
if entry.get("class", "") == type_str:
|
||||||
|
var script_path: String = entry.get("path", "")
|
||||||
|
var scr: Variant = load(script_path)
|
||||||
|
# Reject non-Resource script classes BEFORE constructing them:
|
||||||
|
# scr.new() runs _init(), and an @tool class_name extending a
|
||||||
|
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
|
||||||
|
# an orphan instance this path never frees. get_instance_base_type()
|
||||||
|
# resolves to the native base, so multi-level custom Resource
|
||||||
|
# hierarchies (B extends A extends Resource) still pass.
|
||||||
|
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
|
||||||
|
if base_or_err is Dictionary:
|
||||||
|
return base_or_err
|
||||||
|
var base_type: StringName = base_or_err
|
||||||
|
if not ClassDB.is_parent_class(base_type, "Resource"):
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
|
||||||
|
if not scr.can_instantiate():
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
|
||||||
|
# Reject scripts whose _init() requires arguments BEFORE scr.new():
|
||||||
|
# scr.new() passes no args, so a required-arg _init raises and aborts
|
||||||
|
# this handler mid-call, null-cascading into a generic "malformed
|
||||||
|
# result" error instead of a clean rejection. get_script_method_list()
|
||||||
|
# reports the effective (incl. inherited) _init; required args =
|
||||||
|
# args - default_args. Statically detectable only — a _init that runs
|
||||||
|
# but throws still falls through to scr.new() and the dispatcher catch.
|
||||||
|
for method in scr.get_script_method_list():
|
||||||
|
if method.get("name", "") == "_init":
|
||||||
|
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
|
||||||
|
if required_args > 0:
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
|
||||||
|
break
|
||||||
|
var made: Variant = scr.new()
|
||||||
|
if made == null or not (made is Resource):
|
||||||
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
|
||||||
|
return made
|
||||||
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
|
||||||
|
|
||||||
|
|
||||||
## Apply a dict of property values to a freshly-instantiated Resource,
|
## Apply a dict of property values to a freshly-instantiated Resource,
|
||||||
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
|
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
|
||||||
## Returns null on success or an error dict on failure.
|
## Returns null on success or an error dict on failure.
|
||||||
@@ -240,9 +281,17 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
|
|||||||
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
|
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
|
||||||
valid.append(prop.name)
|
valid.append(prop.name)
|
||||||
valid.sort()
|
valid.sort()
|
||||||
|
# Name the script's class_name (e.g. MyTestResource) rather than the
|
||||||
|
# native base (Resource) so the hint names the type the agent created,
|
||||||
|
# and point at the real MCP verb — resource_manage(op="get_info") now
|
||||||
|
# answers for project class_name Resources too.
|
||||||
|
var type_label := res.get_class()
|
||||||
|
var res_script: Variant = res.get_script()
|
||||||
|
if res_script is Script and not String(res_script.get_global_name()).is_empty():
|
||||||
|
type_label = String(res_script.get_global_name())
|
||||||
var err := ErrorCodes.make(
|
var err := ErrorCodes.make(
|
||||||
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
ErrorCodes.PROPERTY_NOT_ON_CLASS,
|
||||||
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
|
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
|
||||||
)
|
)
|
||||||
err["error"]["data"] = {"valid_properties": valid}
|
err["error"]["data"] = {"valid_properties": valid}
|
||||||
return err
|
return err
|
||||||
@@ -270,16 +319,15 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
|
|||||||
# resource_create/environment_create callers can populate
|
# resource_create/environment_create callers can populate
|
||||||
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
|
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
|
||||||
var sub_type: String = v.get("__class__", "")
|
var sub_type: String = v.get("__class__", "")
|
||||||
var class_err := _validate_resource_class(sub_type)
|
# Resolve via the shared helper so the nested shortcut accepts both
|
||||||
if class_err != null:
|
# engine built-ins (ClassDB) and project `class_name` Resources,
|
||||||
return class_err
|
# exactly like the top-level resource_create path.
|
||||||
var sub_instance := ClassDB.instantiate(sub_type)
|
var sub_made := _instantiate_resource(sub_type)
|
||||||
if sub_instance == null or not (sub_instance is Resource):
|
if sub_made is Dictionary:
|
||||||
return ErrorCodes.make(
|
# Preserve the property-slot context the inline path used to add.
|
||||||
ErrorCodes.INTERNAL_ERROR,
|
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
|
||||||
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
|
return sub_made
|
||||||
)
|
var sub_res: Resource = sub_made
|
||||||
var sub_res: Resource = sub_instance
|
|
||||||
var remaining: Dictionary = (v as Dictionary).duplicate()
|
var remaining: Dictionary = (v as Dictionary).duplicate()
|
||||||
remaining.erase("__class__")
|
remaining.erase("__class__")
|
||||||
if not remaining.is_empty():
|
if not remaining.is_empty():
|
||||||
@@ -361,6 +409,12 @@ func get_resource_info(params: Dictionary) -> Dictionary:
|
|||||||
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
|
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
|
||||||
|
|
||||||
if not ClassDB.class_exists(type_str):
|
if not ClassDB.class_exists(type_str):
|
||||||
|
# Project class_name Resources aren't in ClassDB; resolve them through the
|
||||||
|
# global script-class registry so get_info answers for the same custom
|
||||||
|
# types resource_create can make. Read-only — never instantiates.
|
||||||
|
var custom_info: Variant = _custom_resource_info(type_str)
|
||||||
|
if custom_info != null:
|
||||||
|
return custom_info
|
||||||
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
|
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
|
||||||
if ClassDB.is_parent_class(type_str, "Node"):
|
if ClassDB.is_parent_class(type_str, "Node"):
|
||||||
return ErrorCodes.make(
|
return ErrorCodes.make(
|
||||||
@@ -396,3 +450,82 @@ func get_resource_info(params: Dictionary) -> Dictionary:
|
|||||||
data["concrete_subclasses"] = class_info.concrete_inheritors
|
data["concrete_subclasses"] = class_info.concrete_inheritors
|
||||||
|
|
||||||
return {"data": data}
|
return {"data": data}
|
||||||
|
|
||||||
|
|
||||||
|
## Resolve a loaded global-class script to its native base type, or an error if
|
||||||
|
## the script failed to load (not a Script) or to compile (empty base type).
|
||||||
|
## Shared by the create and get_info custom-Resource paths so both report a
|
||||||
|
## compile failure rather than a misleading "is not a Resource type (extends )".
|
||||||
|
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
|
||||||
|
if not (scr is Script):
|
||||||
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
|
||||||
|
var base_type: StringName = scr.get_instance_base_type()
|
||||||
|
if String(base_type).is_empty():
|
||||||
|
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
|
||||||
|
return base_type
|
||||||
|
|
||||||
|
|
||||||
|
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
|
||||||
|
## dict, an error dict (for a class_name whose native base is not a Resource), or
|
||||||
|
## null if `type_str` is not a registered global class. Read-only: resolves
|
||||||
|
## properties from the script + its native base WITHOUT instantiating (no _init()).
|
||||||
|
static func _custom_resource_info(type_str: String) -> Variant:
|
||||||
|
for entry in ProjectSettings.get_global_class_list():
|
||||||
|
if entry.get("class", "") != type_str:
|
||||||
|
continue
|
||||||
|
var script_path: String = entry.get("path", "")
|
||||||
|
var scr: Variant = load(script_path)
|
||||||
|
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
|
||||||
|
if base_or_err is Dictionary:
|
||||||
|
return base_or_err
|
||||||
|
var base_type: StringName = base_or_err
|
||||||
|
if not ClassDB.is_parent_class(base_type, "Resource"):
|
||||||
|
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
|
||||||
|
var can_instantiate: bool = scr.can_instantiate()
|
||||||
|
# Inherited (native) properties come from the engine base via ClassDB...
|
||||||
|
var class_info := ClassIntrospection.build(String(base_type), {
|
||||||
|
"sections": ["properties"],
|
||||||
|
"include_inherited": true,
|
||||||
|
"limit": 0,
|
||||||
|
})
|
||||||
|
var props: Array = []
|
||||||
|
for native_prop in class_info.properties:
|
||||||
|
props.append(native_prop)
|
||||||
|
# ...and the script's own (and inherited script) exported properties come
|
||||||
|
# from the Script itself, so we never construct the resource. A real
|
||||||
|
# default isn't available without instantiating, so script props carry an
|
||||||
|
# explicit null — keeping one uniform key set across the array (native
|
||||||
|
# props carry their real default).
|
||||||
|
for raw_prop in scr.get_script_property_list():
|
||||||
|
var prop: Dictionary = raw_prop
|
||||||
|
var usage := int(prop.get("usage", 0))
|
||||||
|
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||||
|
continue
|
||||||
|
props.append({
|
||||||
|
"name": str(prop.get("name", "")),
|
||||||
|
"type": type_string(int(prop.get("type", TYPE_NIL))),
|
||||||
|
"class_name": str(prop.get("class_name", "")),
|
||||||
|
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
|
||||||
|
"hint_string": str(prop.get("hint_string", "")),
|
||||||
|
"usage": usage,
|
||||||
|
"default": null,
|
||||||
|
})
|
||||||
|
props.sort_custom(func(a, b): return a.name < b.name)
|
||||||
|
# parent_class is the immediate script parent when there is one (so a
|
||||||
|
# multi-level chain B -> A -> Resource reports A), else the native base.
|
||||||
|
var parent_name := String(base_type)
|
||||||
|
var base_script: Variant = scr.get_base_script()
|
||||||
|
if base_script is Script and not String(base_script.get_global_name()).is_empty():
|
||||||
|
parent_name = String(base_script.get_global_name())
|
||||||
|
return {"data": {
|
||||||
|
"type": type_str,
|
||||||
|
"parent_class": parent_name,
|
||||||
|
"can_instantiate": can_instantiate,
|
||||||
|
# is_abstract reflects real abstractness (the @abstract annotation),
|
||||||
|
# NOT editor-instantiability — a non-@tool concrete Resource has
|
||||||
|
# can_instantiate()==false in-editor but is not abstract.
|
||||||
|
"is_abstract": scr.is_abstract(),
|
||||||
|
"properties": props,
|
||||||
|
"property_count": props.size(),
|
||||||
|
}}
|
||||||
|
return null
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func connect_signal(params: Dictionary) -> Dictionary:
|
|||||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
|
||||||
|
|
||||||
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
|
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
|
||||||
_undo_redo.add_do_method(source, "connect", signal_name, callable)
|
_undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST)
|
||||||
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
|
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
|
||||||
_undo_redo.commit_action()
|
_undo_redo.commit_action()
|
||||||
|
|
||||||
@@ -174,9 +174,19 @@ func disconnect_signal(params: Dictionary) -> Dictionary:
|
|||||||
if not source.is_connected(signal_name, callable):
|
if not source.is_connected(signal_name, callable):
|
||||||
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
|
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
|
||||||
|
|
||||||
|
# Capture the connection's current flags so undo restores it exactly as it
|
||||||
|
# was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
|
||||||
|
# silently promote a runtime-only connection into one that serializes on the
|
||||||
|
# next save. (The connection still exists at this point — checked above.)
|
||||||
|
var reconnect_flags := 0
|
||||||
|
for conn in source.get_signal_connection_list(signal_name):
|
||||||
|
if conn.get("callable", Callable()) == callable:
|
||||||
|
reconnect_flags = int(conn.get("flags", 0))
|
||||||
|
break
|
||||||
|
|
||||||
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
|
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
|
||||||
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
|
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
|
||||||
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
|
_undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags)
|
||||||
_undo_redo.commit_action()
|
_undo_redo.commit_action()
|
||||||
|
|
||||||
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
|
return {"data": _signal_response(source, signal_name, target, method, scene_root)}
|
||||||
|
|||||||
+281
-46
@@ -40,8 +40,17 @@ const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.g
|
|||||||
const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
|
const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd")
|
||||||
|
|
||||||
const DEV_MODE_SETTING := "godot_ai/dev_mode"
|
const DEV_MODE_SETTING := "godot_ai/dev_mode"
|
||||||
|
## "Change the port + reconfigure your clients" guide. Surfaced from the crash
|
||||||
|
## panel when a foreign process holds the HTTP port — the one piece of recovery
|
||||||
|
## (per-client config rewrite) that doesn't fit in the inline crash body.
|
||||||
|
## Resolved against the installed plugin version at click time (see
|
||||||
|
## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped,
|
||||||
|
## not tip-of-main, which may have drifted from that build's UI.
|
||||||
|
const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md"
|
||||||
|
const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob"
|
||||||
const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000
|
const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000
|
||||||
const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000
|
const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000
|
||||||
|
const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000
|
||||||
static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
|
static var COLOR_MUTED := Color(0.7, 0.7, 0.7)
|
||||||
static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
|
static var COLOR_HEADER := Color(0.95, 0.95, 0.95)
|
||||||
## Used for "in-progress" / "stale, action needed" UI: the startup-grace
|
## Used for "in-progress" / "stale, action needed" UI: the startup-grace
|
||||||
@@ -60,12 +69,13 @@ var _status_icon: ColorRect
|
|||||||
var _status_label: Label
|
var _status_label: Label
|
||||||
var _client_grid: VBoxContainer
|
var _client_grid: VBoxContainer
|
||||||
var _client_configure_all_btn: Button
|
var _client_configure_all_btn: Button
|
||||||
|
var _client_empty_cta_btn: Button
|
||||||
var _clients_summary_label: Label
|
var _clients_summary_label: Label
|
||||||
var _clients_window: Window
|
var _clients_window: Window
|
||||||
var _dev_mode_toggle: CheckButton
|
var _dev_mode_toggle: CheckButton
|
||||||
var _install_label: Label
|
var _install_label: Label
|
||||||
|
|
||||||
# Settings tab (secondary window, Tab 2) — domain-exclusion UI for clients
|
# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients
|
||||||
# that cap total tool count (Antigravity: 100). Pending set is mutated by
|
# that cap total tool count (Antigravity: 100). Pending set is mutated by
|
||||||
# checkbox clicks; saved set reflects what the spawned server actually
|
# checkbox clicks; saved set reflects what the spawned server actually
|
||||||
# sees. `Apply & Restart Server` writes pending → setting and triggers a
|
# sees. `Apply & Restart Server` writes pending → setting and triggers a
|
||||||
@@ -147,23 +157,24 @@ static var _orphaned_client_status_refresh_threads: Array[Thread] = []
|
|||||||
|
|
||||||
## Per-row worker state for Configure / Remove. Issue #239: shelling out
|
## Per-row worker state for Configure / Remove. Issue #239: shelling out
|
||||||
## to a hung CLI on main hangs the editor. We dispatch each click to its
|
## to a hung CLI on main hangs the editor. We dispatch each click to its
|
||||||
## own thread (one slot per client) and apply the result via call_deferred
|
## own thread (one slot per client), then `_process` reaps completed workers
|
||||||
## once the subprocess returns or the wall-clock budget in McpCliExec
|
## and applies returned payloads on main. The buttons stay disabled while
|
||||||
## kicks in. The buttons stay disabled while the slot is busy so the user
|
## the slot is busy so the user can't queue a re-click on the same row.
|
||||||
## can't queue a re-click on the same row.
|
|
||||||
##
|
##
|
||||||
## Per-client (not single-slot) so Configure-all can fan out — the
|
## Per-client (not single-slot) so Configure-all can fan out — the
|
||||||
## workers are independent, only the row UI is shared, and McpCliExec
|
## workers are independent, only the row UI is shared, and McpCliExec
|
||||||
## bounds the wall-clock for each.
|
## bounds the wall-clock for each.
|
||||||
##
|
##
|
||||||
## No orphan-thread list (unlike the refresh worker): action threads
|
## A watchdog can abandon a slot when a worker fails to report completion.
|
||||||
## never get abandoned mid-flight. McpCliExec's wall-clock budget caps
|
## The thread object is retained in `_orphaned_client_action_threads` until
|
||||||
## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager`
|
## it finishes so GDScript does not destroy a live Thread object.
|
||||||
## install-time drain blocks briefly and finishes — there's no path that
|
|
||||||
## "gives up" on an action thread the way `_abandon_client_status_refresh_thread`
|
|
||||||
## does for the refresh worker.
|
|
||||||
var _client_action_threads: Dictionary = {}
|
var _client_action_threads: Dictionary = {}
|
||||||
var _client_action_generations: Dictionary = {}
|
var _client_action_generations: Dictionary = {}
|
||||||
|
var _client_action_started_msec: Dictionary = {}
|
||||||
|
var _client_action_names: Dictionary = {}
|
||||||
|
## Timed-out Configure/Remove workers are abandoned but retained here until
|
||||||
|
## they finish, so GDScript does not destroy a live Thread object.
|
||||||
|
static var _orphaned_client_action_threads: Array[Thread] = []
|
||||||
|
|
||||||
# Dev-mode only
|
# Dev-mode only
|
||||||
var _dev_section: VBoxContainer
|
var _dev_section: VBoxContainer
|
||||||
@@ -193,6 +204,11 @@ var _crash_panel: VBoxContainer
|
|||||||
var _crash_output: RichTextLabel
|
var _crash_output: RichTextLabel
|
||||||
var _crash_restart_btn: Button
|
var _crash_restart_btn: Button
|
||||||
var _crash_reload_btn: Button
|
var _crash_reload_btn: Button
|
||||||
|
## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE
|
||||||
|
## case (no `can_recover_incompatible` proof). The inline body names a free
|
||||||
|
## port; this button carries the per-client reconfigure steps that don't fit
|
||||||
|
## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`.
|
||||||
|
var _crash_docs_btn: Button
|
||||||
## Port-picker escape hatch — visible inside the crash panel when the root
|
## Port-picker escape hatch — visible inside the crash panel when the root
|
||||||
## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
|
## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes
|
||||||
## the EditorSetting and reloads the plugin in response to the panel's
|
## the EditorSetting and reloads the plugin in response to the panel's
|
||||||
@@ -238,10 +254,14 @@ func _ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
|
_prune_orphaned_client_status_refresh_threads()
|
||||||
|
_prune_orphaned_client_action_threads()
|
||||||
|
_poll_completed_client_status_refresh_thread()
|
||||||
|
_poll_completed_client_action_threads()
|
||||||
|
_check_client_status_refresh_timeout()
|
||||||
|
_check_client_action_timeouts()
|
||||||
if _connection == null:
|
if _connection == null:
|
||||||
return
|
return
|
||||||
_prune_orphaned_client_status_refresh_threads()
|
|
||||||
_check_client_status_refresh_timeout()
|
|
||||||
_retry_deferred_client_status_refresh()
|
_retry_deferred_client_status_refresh()
|
||||||
_update_status()
|
_update_status()
|
||||||
if _log_viewer != null and _log_viewer.visible:
|
if _log_viewer != null and _log_viewer.visible:
|
||||||
@@ -275,6 +295,8 @@ func _exit_tree() -> void:
|
|||||||
## drains directly because it has additional state-machine work
|
## drains directly because it has additional state-machine work
|
||||||
## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit.
|
## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit.
|
||||||
func prepare_for_self_update_drain() -> void:
|
func prepare_for_self_update_drain() -> void:
|
||||||
|
_poll_completed_client_status_refresh_thread()
|
||||||
|
_poll_completed_client_action_threads()
|
||||||
_drain_client_status_refresh_workers()
|
_drain_client_status_refresh_workers()
|
||||||
_drain_client_action_workers()
|
_drain_client_action_workers()
|
||||||
|
|
||||||
@@ -309,13 +331,13 @@ func _drain_client_action_workers() -> void:
|
|||||||
## plugin disable / install-update path reloads our script class, so any
|
## plugin disable / install-update path reloads our script class, so any
|
||||||
## live Thread must finish before its slot is GC'd or we hit
|
## live Thread must finish before its slot is GC'd or we hit
|
||||||
## `~Thread … destroyed without its completion having been realized` →
|
## `~Thread … destroyed without its completion having been realized` →
|
||||||
## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the
|
## VM corruption. Normal UI recovery is handled by the per-row watchdog;
|
||||||
## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV.
|
## teardown still blocks because GDScript's Thread API has no kill/timeout
|
||||||
|
## primitive and destroying a live Thread corrupts the VM.
|
||||||
##
|
##
|
||||||
## Generation-bumped per-row so any pending `call_deferred(
|
## Generation-bumped per-row so any result from a worker that finished
|
||||||
## "_apply_client_action_result")` from a worker that finished after we
|
## after we started draining detects the generation mismatch and
|
||||||
## started draining detects the generation mismatch and short-circuits
|
## short-circuits without touching freed UI state.
|
||||||
## without touching freed UI state.
|
|
||||||
##
|
##
|
||||||
## After draining, restore the row UI for any in-flight rows: bare
|
## After draining, restore the row UI for any in-flight rows: bare
|
||||||
## `_client_action_threads.clear()` would leave the dock stuck showing
|
## `_client_action_threads.clear()` would leave the dock stuck showing
|
||||||
@@ -328,6 +350,8 @@ func _drain_client_action_workers() -> void:
|
|||||||
if t != null:
|
if t != null:
|
||||||
t.wait_to_finish()
|
t.wait_to_finish()
|
||||||
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
|
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
|
||||||
|
_client_action_started_msec.erase(client_id)
|
||||||
|
_client_action_names.erase(client_id)
|
||||||
_finalize_action_buttons(String(client_id))
|
_finalize_action_buttons(String(client_id))
|
||||||
var row: Dictionary = _client_rows.get(String(client_id), {})
|
var row: Dictionary = _client_rows.get(String(client_id), {})
|
||||||
if not row.is_empty():
|
if not row.is_empty():
|
||||||
@@ -337,6 +361,71 @@ func _drain_client_action_workers() -> void:
|
|||||||
""
|
""
|
||||||
)
|
)
|
||||||
_client_action_threads.clear()
|
_client_action_threads.clear()
|
||||||
|
for thread in _orphaned_client_action_threads:
|
||||||
|
if thread != null:
|
||||||
|
thread.wait_to_finish()
|
||||||
|
_orphaned_client_action_threads.clear()
|
||||||
|
_client_action_started_msec.clear()
|
||||||
|
_client_action_names.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _check_client_action_timeouts() -> void:
|
||||||
|
var now := Time.get_ticks_msec()
|
||||||
|
for client_id in _client_action_threads.keys():
|
||||||
|
if not _client_action_started_msec.has(client_id):
|
||||||
|
continue
|
||||||
|
var started := int(_client_action_started_msec.get(client_id, 0))
|
||||||
|
if now - started >= CLIENT_ACTION_TIMEOUT_MSEC:
|
||||||
|
_abandon_client_action_thread(String(client_id))
|
||||||
|
|
||||||
|
|
||||||
|
func _abandon_client_action_thread(client_id: String) -> void:
|
||||||
|
if not _client_action_threads.has(client_id):
|
||||||
|
return
|
||||||
|
var thread: Thread = _client_action_threads[client_id]
|
||||||
|
var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec()))
|
||||||
|
var worker_alive := thread != null and thread.is_alive()
|
||||||
|
if thread != null:
|
||||||
|
_orphaned_client_action_threads.append(thread)
|
||||||
|
_client_action_threads.erase(client_id)
|
||||||
|
_client_action_started_msec.erase(client_id)
|
||||||
|
var action := str(_client_action_names.get(client_id, "configure"))
|
||||||
|
_client_action_names.erase(client_id)
|
||||||
|
_client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1
|
||||||
|
_finalize_action_buttons(client_id)
|
||||||
|
print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [
|
||||||
|
client_id,
|
||||||
|
action,
|
||||||
|
elapsed,
|
||||||
|
str(worker_alive),
|
||||||
|
])
|
||||||
|
var label := "Remove" if action == "remove" else "Configure"
|
||||||
|
_apply_row_status(
|
||||||
|
client_id,
|
||||||
|
Client.Status.ERROR,
|
||||||
|
"%s did not report completion in time; refreshing current status." % label
|
||||||
|
)
|
||||||
|
_refresh_clients_summary()
|
||||||
|
if is_inside_tree():
|
||||||
|
_request_client_status_refresh(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _prune_orphaned_client_action_threads() -> void:
|
||||||
|
var completed_orphan := false
|
||||||
|
for i in range(_orphaned_client_action_threads.size() - 1, -1, -1):
|
||||||
|
var thread := _orphaned_client_action_threads[i]
|
||||||
|
if thread == null:
|
||||||
|
_orphaned_client_action_threads.remove_at(i)
|
||||||
|
elif not thread.is_alive():
|
||||||
|
thread.wait_to_finish()
|
||||||
|
_orphaned_client_action_threads.remove_at(i)
|
||||||
|
completed_orphan = true
|
||||||
|
if completed_orphan and is_inside_tree():
|
||||||
|
_request_client_action_completion_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _request_client_action_completion_refresh() -> void:
|
||||||
|
_request_client_status_refresh(true)
|
||||||
|
|
||||||
|
|
||||||
func _notification(what: int) -> void:
|
func _notification(what: int) -> void:
|
||||||
@@ -473,6 +562,13 @@ func _build_ui() -> void:
|
|||||||
_crash_reload_btn.pressed.connect(_on_reload_plugin)
|
_crash_reload_btn.pressed.connect(_on_reload_plugin)
|
||||||
_crash_panel.add_child(_crash_reload_btn)
|
_crash_panel.add_child(_crash_reload_btn)
|
||||||
|
|
||||||
|
_crash_docs_btn = Button.new()
|
||||||
|
_crash_docs_btn.text = "How to change the port"
|
||||||
|
_crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients"
|
||||||
|
_crash_docs_btn.visible = false
|
||||||
|
_crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url()))
|
||||||
|
_crash_panel.add_child(_crash_docs_btn)
|
||||||
|
|
||||||
_crash_panel.add_child(HSeparator.new())
|
_crash_panel.add_child(HSeparator.new())
|
||||||
add_child(_crash_panel)
|
add_child(_crash_panel)
|
||||||
|
|
||||||
@@ -558,30 +654,45 @@ func _build_ui() -> void:
|
|||||||
add_child(HSeparator.new())
|
add_child(HSeparator.new())
|
||||||
|
|
||||||
# --- Clients ---
|
# --- Clients ---
|
||||||
var clients_row := HBoxContainer.new()
|
var clients_header_row := HBoxContainer.new()
|
||||||
clients_row.add_theme_constant_override("separation", 8)
|
clients_header_row.add_theme_constant_override("separation", 8)
|
||||||
|
|
||||||
var clients_header := _make_header("Clients")
|
var clients_header := _make_header("Clients")
|
||||||
clients_row.add_child(clients_header)
|
clients_header_row.add_child(clients_header)
|
||||||
|
|
||||||
_clients_summary_label = Label.new()
|
_clients_summary_label = Label.new()
|
||||||
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
|
_clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED)
|
||||||
|
_clients_summary_label.clip_text = true
|
||||||
|
_clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
_clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
clients_row.add_child(_clients_summary_label)
|
clients_header_row.add_child(_clients_summary_label)
|
||||||
|
|
||||||
|
var clients_actions := HFlowContainer.new()
|
||||||
|
clients_actions.add_theme_constant_override("h_separation", 8)
|
||||||
|
clients_actions.add_theme_constant_override("v_separation", 4)
|
||||||
|
|
||||||
var clients_refresh_btn := Button.new()
|
var clients_refresh_btn := Button.new()
|
||||||
clients_refresh_btn.text = "Refresh"
|
clients_refresh_btn.text = "Refresh"
|
||||||
clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
|
clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run."
|
||||||
clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
|
clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed)
|
||||||
clients_row.add_child(clients_refresh_btn)
|
clients_actions.add_child(clients_refresh_btn)
|
||||||
|
|
||||||
var clients_open_btn := Button.new()
|
var clients_open_btn := Button.new()
|
||||||
clients_open_btn.text = "Clients & Settings"
|
clients_open_btn.text = "Clients & Tools"
|
||||||
clients_open_btn.tooltip_text = "Open the MCP settings window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
|
clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)."
|
||||||
clients_open_btn.pressed.connect(_on_open_clients_window)
|
clients_open_btn.pressed.connect(_on_open_clients_window)
|
||||||
clients_row.add_child(clients_open_btn)
|
clients_actions.add_child(clients_open_btn)
|
||||||
|
|
||||||
add_child(clients_row)
|
add_child(clients_header_row)
|
||||||
|
add_child(clients_actions)
|
||||||
|
|
||||||
|
_client_empty_cta_btn = Button.new()
|
||||||
|
_client_empty_cta_btn.text = "Configure an AI client ->"
|
||||||
|
_client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server."
|
||||||
|
_client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_client_empty_cta_btn.visible = false
|
||||||
|
_client_empty_cta_btn.pressed.connect(_on_open_clients_window)
|
||||||
|
add_child(_client_empty_cta_btn)
|
||||||
|
|
||||||
# Drift banner — hidden until a sweep finds at least one mismatched client.
|
# Drift banner — hidden until a sweep finds at least one mismatched client.
|
||||||
_drift_banner = VBoxContainer.new()
|
_drift_banner = VBoxContainer.new()
|
||||||
@@ -600,7 +711,7 @@ func _build_ui() -> void:
|
|||||||
add_child(_drift_banner)
|
add_child(_drift_banner)
|
||||||
|
|
||||||
_clients_window = Window.new()
|
_clients_window = Window.new()
|
||||||
_clients_window.title = "MCP Clients & Settings"
|
_clients_window.title = "Godot AI"
|
||||||
## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
|
## `Vector2i * float` yields Vector2; wrap the result back to Vector2i.
|
||||||
_clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
|
_clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale())
|
||||||
_clients_window.visible = false
|
_clients_window.visible = false
|
||||||
@@ -758,7 +869,7 @@ func _build_client_row(client_id: String) -> void:
|
|||||||
# --- Status updates ---
|
# --- Status updates ---
|
||||||
|
|
||||||
func _update_status() -> void:
|
func _update_status() -> void:
|
||||||
var connected: bool = _connection.is_connected
|
var connected: bool = _connection != null and _connection.is_connected
|
||||||
## During plugin self-update there's a brief window where this dock
|
## During plugin self-update there's a brief window where this dock
|
||||||
## script is already the new version (Godot hot-reloads scripts on
|
## script is already the new version (Godot hot-reloads scripts on
|
||||||
## file change) but `_plugin` is still the old `EditorPlugin` instance
|
## file change) but `_plugin` is still the old `EditorPlugin` instance
|
||||||
@@ -785,7 +896,7 @@ func _update_status() -> void:
|
|||||||
status_text = "Restarting server..."
|
status_text = "Restarting server..."
|
||||||
status_color = COLOR_AMBER
|
status_color = COLOR_AMBER
|
||||||
elif connected:
|
elif connected:
|
||||||
status_text = "Connected"
|
status_text = _connected_status_text()
|
||||||
status_color = Color.GREEN
|
status_color = Color.GREEN
|
||||||
elif state == ServerStateScript.CRASHED:
|
elif state == ServerStateScript.CRASHED:
|
||||||
var exit_ms: int = server_status.get("exit_ms", 0)
|
var exit_ms: int = server_status.get("exit_ms", 0)
|
||||||
@@ -858,6 +969,15 @@ func _update_crash_panel(server_status: Dictionary) -> void:
|
|||||||
not show_recovery_restart
|
not show_recovery_restart
|
||||||
and state != ServerStateScript.INCOMPATIBLE
|
and state != ServerStateScript.INCOMPATIBLE
|
||||||
)
|
)
|
||||||
|
## Docs link only for the genuinely-foreign occupant: a recoverable
|
||||||
|
## (older godot-ai) server gets Restart Server instead, and the inline
|
||||||
|
## body already names a free port — the link carries the per-client
|
||||||
|
## reconfigure steps that don't fit inline.
|
||||||
|
if _crash_docs_btn != null:
|
||||||
|
_crash_docs_btn.visible = (
|
||||||
|
state == ServerStateScript.INCOMPATIBLE
|
||||||
|
and not bool(server_status.get("can_recover_incompatible", false))
|
||||||
|
)
|
||||||
|
|
||||||
var port_picker_visible := (
|
var port_picker_visible := (
|
||||||
state == ServerStateScript.PORT_EXCLUDED
|
state == ServerStateScript.PORT_EXCLUDED
|
||||||
@@ -887,9 +1007,16 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
|
|||||||
if not message.is_empty():
|
if not message.is_empty():
|
||||||
return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
|
return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected]
|
||||||
return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
|
return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected]
|
||||||
|
## Genuinely foreign occupant (no recovery proof). Name a concrete
|
||||||
|
## free port so the user doesn't have to hunt for one, and let the
|
||||||
|
## crash panel's "How to change the port" link carry the per-client
|
||||||
|
## reconfigure steps. `suggest_free_port` already routes through the
|
||||||
|
## Windows reservation table, so the named port won't itself fail
|
||||||
|
## with WinError 10013.
|
||||||
|
var hint := _free_port_hint(port)
|
||||||
if not message.is_empty():
|
if not message.is_empty():
|
||||||
return message
|
return "%s %s" % [message, hint]
|
||||||
return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port
|
return "Port %d is occupied by an incompatible server. %s" % [port, hint]
|
||||||
ServerStateScript.FOREIGN_PORT:
|
ServerStateScript.FOREIGN_PORT:
|
||||||
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
|
return "Another process is already bound to port %d. Pick a free port or stop the other process." % port
|
||||||
ServerStateScript.CRASHED:
|
ServerStateScript.CRASHED:
|
||||||
@@ -907,6 +1034,31 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) ->
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
## One sentence naming concrete free ports for the user to switch to. Names
|
||||||
|
## BOTH http and ws: this branch also fires for an incompatible godot-ai
|
||||||
|
## server we can't prove we own, which commonly holds both ports — moving only
|
||||||
|
## http would then leave the new server unable to bind ws. Both suggestions are
|
||||||
|
## routed through `suggest_free_port` so they clear Windows' winnat reservation
|
||||||
|
## table (no point suggesting a port that 10013s on bind). Only the http port
|
||||||
|
## reaches client configs; the ws port is server↔plugin, hence the wording.
|
||||||
|
## The per-client reconfigure steps live behind the crash panel's docs link.
|
||||||
|
static func _free_port_hint(port: int) -> String:
|
||||||
|
var free_http := ClientConfigurator.suggest_free_port(port + 1)
|
||||||
|
var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1)
|
||||||
|
return "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [free_http, free_ws]
|
||||||
|
|
||||||
|
|
||||||
|
## URL for the port-conflict guide, pinned to the release tag that matches the
|
||||||
|
## installed plugin version (releases are tagged `v<version>`). The crash-panel
|
||||||
|
## button only exists in builds that ship `docs/port-conflicts.md`, so the
|
||||||
|
## versioned ref always resolves — and a shipped build never points users at a
|
||||||
|
## tip-of-main guide that has drifted from its own UI.
|
||||||
|
static func _port_conflict_docs_url() -> String:
|
||||||
|
var version := ClientConfigurator.get_plugin_version()
|
||||||
|
var git_ref := ("v%s" % version) if not version.is_empty() else "main"
|
||||||
|
return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH]
|
||||||
|
|
||||||
|
|
||||||
## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner`
|
## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner`
|
||||||
## confirms `*.update_backup` files exist in the addons tree. Mirrors the
|
## confirms `*.update_backup` files exist in the addons tree. Mirrors the
|
||||||
## issue #354 fix shape: structured, agent-readable diagnostic that survives
|
## issue #354 fix shape: structured, agent-readable diagnostic that survives
|
||||||
@@ -1480,6 +1632,30 @@ func _update_dev_section_buttons() -> void:
|
|||||||
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
|
_dev_stop_btn.tooltip_text = stop_state["tooltip"]
|
||||||
|
|
||||||
|
|
||||||
|
func _configured_client_count() -> int:
|
||||||
|
var configured := 0
|
||||||
|
for client_id in _client_rows:
|
||||||
|
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
||||||
|
if status == Client.Status.CONFIGURED:
|
||||||
|
configured += 1
|
||||||
|
return configured
|
||||||
|
|
||||||
|
|
||||||
|
func _client_status_refresh_has_completed() -> bool:
|
||||||
|
return _last_client_status_refresh_completed_msec > 0
|
||||||
|
|
||||||
|
|
||||||
|
func _connected_status_text() -> String:
|
||||||
|
var configured := _configured_client_count()
|
||||||
|
if configured == 0:
|
||||||
|
if not _client_status_refresh_has_completed():
|
||||||
|
return "Server connected · checking AI client configuration"
|
||||||
|
return "Server connected · no AI client configured"
|
||||||
|
if configured == 1:
|
||||||
|
return "Server connected · 1 AI client configured"
|
||||||
|
return "Server connected · %d AI clients configured" % configured
|
||||||
|
|
||||||
|
|
||||||
func _on_install_uv() -> void:
|
func _on_install_uv() -> void:
|
||||||
match OS.get_name():
|
match OS.get_name():
|
||||||
"Windows":
|
"Windows":
|
||||||
@@ -1548,28 +1724,65 @@ func _dispatch_client_action(client_id: String, action: String) -> void:
|
|||||||
_client_action_generations[client_id] = generation
|
_client_action_generations[client_id] = generation
|
||||||
var thread := Thread.new()
|
var thread := Thread.new()
|
||||||
_client_action_threads[client_id] = thread
|
_client_action_threads[client_id] = thread
|
||||||
|
_client_action_started_msec[client_id] = Time.get_ticks_msec()
|
||||||
|
_client_action_names[client_id] = action
|
||||||
var err := thread.start(
|
var err := thread.start(
|
||||||
Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation)
|
Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation)
|
||||||
)
|
)
|
||||||
if err != OK:
|
if err != OK:
|
||||||
_client_action_threads.erase(client_id)
|
_client_action_threads.erase(client_id)
|
||||||
|
_client_action_started_msec.erase(client_id)
|
||||||
|
_client_action_names.erase(client_id)
|
||||||
_finalize_action_buttons(client_id)
|
_finalize_action_buttons(client_id)
|
||||||
_apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread")
|
_apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread")
|
||||||
_refresh_clients_summary()
|
_refresh_clients_summary()
|
||||||
|
|
||||||
|
|
||||||
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void:
|
func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> Dictionary:
|
||||||
var result: Dictionary
|
var result: Dictionary
|
||||||
if action == "remove":
|
if action == "remove":
|
||||||
result = ClientConfigurator.remove(client_id, server_url)
|
result = ClientConfigurator.remove(client_id, server_url)
|
||||||
else:
|
else:
|
||||||
result = ClientConfigurator.configure(client_id, server_url)
|
result = ClientConfigurator.configure(client_id, server_url)
|
||||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
return {
|
||||||
call_deferred("_apply_client_action_result", client_id, action, result, generation)
|
"client_id": client_id,
|
||||||
|
"action": action,
|
||||||
|
"result": result,
|
||||||
|
"generation": generation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _poll_completed_client_action_threads() -> void:
|
||||||
|
for client_id in _client_action_threads.keys():
|
||||||
|
var thread: Thread = _client_action_threads[client_id]
|
||||||
|
if thread == null or thread.is_alive():
|
||||||
|
continue
|
||||||
|
var payload: Variant = thread.wait_to_finish()
|
||||||
|
_client_action_threads[client_id] = null
|
||||||
|
if payload is Dictionary:
|
||||||
|
var data := payload as Dictionary
|
||||||
|
var result: Dictionary = data.get("result", {})
|
||||||
|
_apply_client_action_result(
|
||||||
|
String(data.get("client_id", client_id)),
|
||||||
|
String(data.get("action", _client_action_names.get(client_id, "configure"))),
|
||||||
|
result,
|
||||||
|
int(data.get("generation", _client_action_generations.get(client_id, 0)))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_apply_client_action_result(
|
||||||
|
String(client_id),
|
||||||
|
String(_client_action_names.get(client_id, "configure")),
|
||||||
|
{"status": "error", "message": "worker returned no result"},
|
||||||
|
int(_client_action_generations.get(client_id, 0))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void:
|
func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void:
|
||||||
if int(_client_action_generations.get(client_id, 0)) != generation:
|
if int(_client_action_generations.get(client_id, 0)) != generation:
|
||||||
|
if _client_action_threads.get(client_id, null) == null:
|
||||||
|
_client_action_threads.erase(client_id)
|
||||||
|
_client_action_started_msec.erase(client_id)
|
||||||
|
_client_action_names.erase(client_id)
|
||||||
return
|
return
|
||||||
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
|
if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN:
|
||||||
return
|
return
|
||||||
@@ -1577,7 +1790,9 @@ func _apply_client_action_result(client_id: String, action: String, result: Dict
|
|||||||
var t: Thread = _client_action_threads[client_id]
|
var t: Thread = _client_action_threads[client_id]
|
||||||
if t != null:
|
if t != null:
|
||||||
t.wait_to_finish()
|
t.wait_to_finish()
|
||||||
_client_action_threads.erase(client_id)
|
_client_action_threads.erase(client_id)
|
||||||
|
_client_action_started_msec.erase(client_id)
|
||||||
|
_client_action_names.erase(client_id)
|
||||||
_finalize_action_buttons(client_id)
|
_finalize_action_buttons(client_id)
|
||||||
if _server_blocks_client_health():
|
if _server_blocks_client_health():
|
||||||
_apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message())
|
_apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message())
|
||||||
@@ -1645,7 +1860,7 @@ func _on_configure_all_clients() -> void:
|
|||||||
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
_apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message())
|
||||||
_refresh_clients_summary()
|
_refresh_clients_summary()
|
||||||
return
|
return
|
||||||
if ClientRefreshStateScript.has_worker_alive(_refresh_state):
|
if ClientRefreshStateScript.should_disable_client_actions(_refresh_state):
|
||||||
return
|
return
|
||||||
for client_id in _client_rows:
|
for client_id in _client_rows:
|
||||||
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED)
|
||||||
@@ -1700,7 +1915,7 @@ func _build_tools_tab(tabs: TabContainer) -> void:
|
|||||||
var tools_tab := VBoxContainer.new()
|
var tools_tab := VBoxContainer.new()
|
||||||
tools_tab.add_theme_constant_override("separation", 8)
|
tools_tab.add_theme_constant_override("separation", 8)
|
||||||
var tools_margin := _build_margin_container()
|
var tools_margin := _build_margin_container()
|
||||||
tools_margin.name = "Settings"
|
tools_margin.name = "Tools"
|
||||||
tools_margin.add_child(tools_tab)
|
tools_margin.add_child(tools_tab)
|
||||||
tabs.add_child(tools_margin)
|
tabs.add_child(tools_margin)
|
||||||
|
|
||||||
@@ -1976,8 +2191,11 @@ func _refresh_clients_summary() -> void:
|
|||||||
)
|
)
|
||||||
_clients_summary_label.text = text
|
_clients_summary_label.text = text
|
||||||
if _client_configure_all_btn != null:
|
if _client_configure_all_btn != null:
|
||||||
_client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state)
|
_client_configure_all_btn.disabled = ClientRefreshStateScript.should_disable_client_actions(_refresh_state)
|
||||||
|
if _client_empty_cta_btn != null:
|
||||||
|
_client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed()
|
||||||
_refresh_drift_banner(mismatched_ids)
|
_refresh_drift_banner(mismatched_ids)
|
||||||
|
_update_status()
|
||||||
|
|
||||||
|
|
||||||
func _show_manual_command_for(client_id: String) -> void:
|
func _show_manual_command_for(client_id: String) -> void:
|
||||||
@@ -2167,8 +2385,8 @@ func _warm_strategy_bytecode() -> void:
|
|||||||
|
|
||||||
func _begin_client_status_refresh_run() -> int:
|
func _begin_client_status_refresh_run() -> int:
|
||||||
## Marks a refresh as starting and returns the new generation token.
|
## Marks a refresh as starting and returns the new generation token.
|
||||||
## Generation is bumped here (not at completion) so that a worker callback
|
## Generation is bumped here (not at completion) so that a worker result
|
||||||
## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree`
|
## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree`
|
||||||
## fires can be detected as stale via generation mismatch.
|
## fires can be detected as stale via generation mismatch.
|
||||||
_refresh_state = ClientRefreshStateScript.RUNNING
|
_refresh_state = ClientRefreshStateScript.RUNNING
|
||||||
_client_status_refresh_pending = false
|
_client_status_refresh_pending = false
|
||||||
@@ -2181,7 +2399,7 @@ func _begin_client_status_refresh_run() -> int:
|
|||||||
|
|
||||||
func _finalize_completed_refresh() -> void:
|
func _finalize_completed_refresh() -> void:
|
||||||
## Stamps cooldown and clears in-flight state. Called at the end of every
|
## Stamps cooldown and clears in-flight state. Called at the end of every
|
||||||
## refresh that successfully applied results — the worker callback path
|
## refresh that successfully applied results — the worker reaping path
|
||||||
## and the no-CLI fast path in `_perform_initial_client_status_refresh`.
|
## and the no-CLI fast path in `_perform_initial_client_status_refresh`.
|
||||||
_last_client_status_refresh_completed_msec = Time.get_ticks_msec()
|
_last_client_status_refresh_completed_msec = Time.get_ticks_msec()
|
||||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
||||||
@@ -2308,7 +2526,7 @@ func _retry_deferred_client_status_refresh() -> void:
|
|||||||
_request_client_status_refresh(force)
|
_request_client_status_refresh(force)
|
||||||
|
|
||||||
|
|
||||||
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void:
|
func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> Dictionary:
|
||||||
var results: Dictionary = {}
|
var results: Dictionary = {}
|
||||||
for probe in client_probes:
|
for probe in client_probes:
|
||||||
var client_id := String(probe.get("id", ""))
|
var client_id := String(probe.get("id", ""))
|
||||||
@@ -2325,8 +2543,25 @@ func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_
|
|||||||
"installed": installed,
|
"installed": installed,
|
||||||
"error_msg": details.get("error_msg", ""),
|
"error_msg": details.get("error_msg", ""),
|
||||||
}
|
}
|
||||||
if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN:
|
return {"results": results, "generation": generation}
|
||||||
call_deferred("_apply_client_status_refresh_results", results, generation)
|
|
||||||
|
|
||||||
|
func _poll_completed_client_status_refresh_thread() -> void:
|
||||||
|
if _client_status_refresh_thread == null:
|
||||||
|
return
|
||||||
|
if _client_status_refresh_thread.is_alive():
|
||||||
|
return
|
||||||
|
var payload: Variant = _client_status_refresh_thread.wait_to_finish()
|
||||||
|
_client_status_refresh_thread = null
|
||||||
|
if payload is Dictionary:
|
||||||
|
var data := payload as Dictionary
|
||||||
|
var results: Dictionary = data.get("results", {})
|
||||||
|
_apply_client_status_refresh_results(
|
||||||
|
results,
|
||||||
|
int(data.get("generation", _client_status_refresh_generation))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_apply_client_status_refresh_results({}, _client_status_refresh_generation)
|
||||||
|
|
||||||
|
|
||||||
func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void:
|
func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void:
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
name="Godot AI"
|
name="Godot AI"
|
||||||
description="MCP server and AI tools for Godot"
|
description="MCP server and AI tools for Godot"
|
||||||
author="Godot AI"
|
author="Godot AI"
|
||||||
version="2.7.6"
|
version="2.8.1"
|
||||||
script="plugin.gd"
|
script="plugin.gd"
|
||||||
|
|||||||
@@ -233,14 +233,14 @@ func _enter_tree() -> void:
|
|||||||
|
|
||||||
_telemetry = Telemetry.new(_connection)
|
_telemetry = Telemetry.new(_connection)
|
||||||
|
|
||||||
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer)
|
_debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer)
|
||||||
add_debugger_plugin(_debugger_plugin)
|
add_debugger_plugin(_debugger_plugin)
|
||||||
_ensure_game_helper_autoload()
|
_ensure_game_helper_autoload()
|
||||||
|
|
||||||
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer)
|
var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer)
|
||||||
var scene_handler := SceneHandler.new(_connection)
|
var scene_handler := SceneHandler.new(_connection)
|
||||||
var node_handler := NodeHandler.new(get_undo_redo())
|
var node_handler := NodeHandler.new(get_undo_redo())
|
||||||
var project_handler := ProjectHandler.new(_connection, _debugger_plugin)
|
var project_handler := ProjectHandler.new(_connection, _debugger_plugin, _editor_log_buffer)
|
||||||
var client_handler := ClientHandler.new()
|
var client_handler := ClientHandler.new()
|
||||||
var script_handler := ScriptHandler.new(get_undo_redo(), _connection)
|
var script_handler := ScriptHandler.new(get_undo_redo(), _connection)
|
||||||
var resource_handler := ResourceHandler.new(get_undo_redo(), _connection)
|
var resource_handler := ResourceHandler.new(get_undo_redo(), _connection)
|
||||||
|
|||||||
@@ -81,9 +81,9 @@ func _log_error(
|
|||||||
## Collect every function name in the first non-empty backtrace so
|
## Collect every function name in the first non-empty backtrace so
|
||||||
## game_helper can match its eval's uniquely named wrapper function.
|
## game_helper can match its eval's uniquely named wrapper function.
|
||||||
var funcs := PackedStringArray()
|
var funcs := PackedStringArray()
|
||||||
for bt in script_backtraces:
|
for bt: RefCounted in script_backtraces:
|
||||||
if bt != null and bt.get_frame_count() > 0:
|
if bt != null and bt.get_frame_count() > 0:
|
||||||
for i in bt.get_frame_count():
|
for i: int in bt.get_frame_count():
|
||||||
funcs.append(bt.get_frame_function(i))
|
funcs.append(bt.get_frame_function(i))
|
||||||
break
|
break
|
||||||
_mutex.lock()
|
_mutex.lock()
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ extends McpStructuredLogRing
|
|||||||
## ferried back from the playing game over the EngineDebugger channel.
|
## ferried back from the playing game over the EngineDebugger channel.
|
||||||
##
|
##
|
||||||
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
|
## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id`
|
||||||
## rotates each time clear_for_new_run() fires (called on the game's
|
## rotates at play-start, giving agents a stable cursor for "lines from
|
||||||
## mcp:hello boot beacon), giving agents a stable cursor for "lines since
|
## this run" even when the game never reaches the mcp:hello boot beacon.
|
||||||
## this play started".
|
|
||||||
##
|
##
|
||||||
## Single-threaded — game_helper.gd drains its logger from `_process` and
|
## Single-threaded — game_helper.gd drains its logger from `_process` and
|
||||||
## calls `append` from the main thread, so this subclass can use the base
|
## calls `append` from the main thread, so this subclass can use the base
|
||||||
@@ -17,6 +16,7 @@ extends McpStructuredLogRing
|
|||||||
const MAX_LINES := 2000
|
const MAX_LINES := 2000
|
||||||
|
|
||||||
var _run_id := ""
|
var _run_id := ""
|
||||||
|
var _run_seq := 0
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
@@ -24,17 +24,22 @@ func _init() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func append(level: String, text: String, details: Dictionary = {}) -> void:
|
func append(level: String, text: String, details: Dictionary = {}) -> void:
|
||||||
var entry := {"source": "game", "level": _coerce_level(level), "text": text}
|
var entry := {
|
||||||
|
"source": "game",
|
||||||
|
"level": _coerce_level(level),
|
||||||
|
"text": text,
|
||||||
|
"run_id": _run_id,
|
||||||
|
}
|
||||||
if not details.is_empty():
|
if not details.is_empty():
|
||||||
entry["details"] = details.duplicate(true)
|
entry["details"] = details.duplicate(true)
|
||||||
_append_entry(entry)
|
_append_entry(entry)
|
||||||
|
|
||||||
|
|
||||||
## Rotate the run identifier and drop all buffered entries. Called when the
|
## Rotate the run identifier without dropping buffered entries. Called at
|
||||||
## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle.
|
## play-start so even no-hello parse failures get a fresh current-run identity.
|
||||||
## Returns the new run_id.
|
## Historical lines stay tagged with their original run_id and can still be
|
||||||
|
## queried explicitly.
|
||||||
func clear_for_new_run() -> String:
|
func clear_for_new_run() -> String:
|
||||||
_clear_storage()
|
|
||||||
_run_id = _generate_run_id()
|
_run_id = _generate_run_id()
|
||||||
return _run_id
|
return _run_id
|
||||||
|
|
||||||
@@ -43,8 +48,38 @@ func run_id() -> String:
|
|||||||
return _run_id
|
return _run_id
|
||||||
|
|
||||||
|
|
||||||
static func _generate_run_id() -> String:
|
func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]:
|
||||||
|
return get_run_page(run_id, offset, count).entries
|
||||||
|
|
||||||
|
|
||||||
|
func run_total_count(run_id: String) -> int:
|
||||||
|
return int(get_run_page(run_id, 0, 0).total_count)
|
||||||
|
|
||||||
|
|
||||||
|
func get_run_page(run_id: String, offset: int, count: int) -> Dictionary:
|
||||||
|
var entries := _entries_for_run(run_id)
|
||||||
|
var start := mini(maxi(0, offset), entries.size())
|
||||||
|
var stop := mini(entries.size(), start + maxi(0, count))
|
||||||
|
var out: Array[Dictionary] = []
|
||||||
|
for i in range(start, stop):
|
||||||
|
out.append(entries[i])
|
||||||
|
return {
|
||||||
|
"entries": out,
|
||||||
|
"total_count": entries.size(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _entries_for_run(run_id: String) -> Array[Dictionary]:
|
||||||
|
var out: Array[Dictionary] = []
|
||||||
|
for entry in get_range(0, total_count()):
|
||||||
|
if str(entry.get("run_id", "")) == run_id:
|
||||||
|
out.append(entry)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _generate_run_id() -> String:
|
||||||
## Opaque to agents — they only check equality. Time-based is plenty
|
## Opaque to agents — they only check equality. Time-based is plenty
|
||||||
## unique within a single editor session and avoids the RNG-seed
|
## unique within a single editor session; the local sequence protects
|
||||||
## reproducibility footgun.
|
## fast back-to-back test runs within the same millisecond.
|
||||||
return "r%d" % Time.get_ticks_msec()
|
_run_seq += 1
|
||||||
|
return "r%d-%d" % [Time.get_ticks_msec(), _run_seq]
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ static func has_worker_alive(state: int) -> bool:
|
|||||||
return state == RUNNING or state == RUNNING_TIMED_OUT
|
return state == RUNNING or state == RUNNING_TIMED_OUT
|
||||||
|
|
||||||
|
|
||||||
|
## True while the status worker is still within its healthy budget. Once a
|
||||||
|
## refresh has timed out, the dock keeps the warning badge but must let users
|
||||||
|
## retry Configure / Configure all instead of stranding the controls behind an
|
||||||
|
## orphaned, uninterruptible worker.
|
||||||
|
static func should_disable_client_actions(state: int) -> bool:
|
||||||
|
return state == RUNNING
|
||||||
|
|
||||||
|
|
||||||
## True when the dock should reject new refresh spawns. Used by the
|
## True when the dock should reject new refresh spawns. Used by the
|
||||||
## focus-in / manual button / cooldown-timer entrypoints.
|
## focus-in / manual button / cooldown-timer entrypoints.
|
||||||
static func is_blocked_for_spawn(state: int) -> bool:
|
static func is_blocked_for_spawn(state: int) -> bool:
|
||||||
|
|||||||
@@ -300,6 +300,15 @@ func _set_incompatible_server(live: Dictionary, expected_version: String, port:
|
|||||||
var proof_name := str(proof.get("proof", ""))
|
var proof_name := str(proof.get("proof", ""))
|
||||||
_can_recover_incompatible = not proof_name.is_empty()
|
_can_recover_incompatible = not proof_name.is_empty()
|
||||||
print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)"))
|
print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)"))
|
||||||
|
if not _can_recover_incompatible:
|
||||||
|
## Non-recoverable: a foreign / unprovable occupant holds the port and
|
||||||
|
## we have no ownership proof, so we must NOT kill it — surface a
|
||||||
|
## concrete free port the user can switch to instead (the same hint
|
||||||
|
## the dock crash body renders). Logging it to the editor output also
|
||||||
|
## lets `ci-stale-server-smoke --mode foreign` assert this upstream
|
||||||
|
## classification from CI. Reservation-aware on Windows.
|
||||||
|
var suggested := ClientConfigurator.suggest_free_port(port + 1)
|
||||||
|
print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, suggested])
|
||||||
_host._refresh_dock_client_statuses()
|
_host._refresh_dock_client_statuses()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ static func _is_trusted_download_url(url: String) -> bool:
|
|||||||
const SCHEME := "https://"
|
const SCHEME := "https://"
|
||||||
if not url.begins_with(SCHEME):
|
if not url.begins_with(SCHEME):
|
||||||
return false
|
return false
|
||||||
|
if url.find("\\") >= 0:
|
||||||
|
return false
|
||||||
var rest := url.substr(SCHEME.length())
|
var rest := url.substr(SCHEME.length())
|
||||||
var authority := rest
|
var authority := rest
|
||||||
var slash := rest.find("/")
|
var slash := rest.find("/")
|
||||||
@@ -457,7 +459,7 @@ func _on_checksum_completed(
|
|||||||
|
|
||||||
print("MCP | self-update checksum verified (sha256 %s)" % actual)
|
print("MCP | self-update checksum verified (sha256 %s)" % actual)
|
||||||
install_state_changed.emit({"button_text": "Installing..."})
|
install_state_changed.emit({"button_text": "Installing..."})
|
||||||
_install_zip()
|
_install_zip.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
## Surface an integrity-check failure and drop the staged zip so the bad
|
## Surface an integrity-check failure and drop the staged zip so the bad
|
||||||
@@ -483,8 +485,9 @@ static func _parse_sha256_digest(text: String) -> String:
|
|||||||
if trimmed.is_empty():
|
if trimmed.is_empty():
|
||||||
return ""
|
return ""
|
||||||
## First whitespace-delimited token; `sha256sum` separates digest and
|
## First whitespace-delimited token; `sha256sum` separates digest and
|
||||||
## filename with two spaces, so allow_empty=false collapses the run.
|
## filename with two spaces, but some tools use tabs.
|
||||||
var tokens := trimmed.split(" ", false)
|
var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ")
|
||||||
|
var tokens := normalized.split(" ", false)
|
||||||
if tokens.is_empty():
|
if tokens.is_empty():
|
||||||
return ""
|
return ""
|
||||||
var digest := String(tokens[0]).strip_edges().to_lower()
|
var digest := String(tokens[0]).strip_edges().to_lower()
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="scene"
|
||||||
|
importer_version=1
|
||||||
|
type="PackedScene"
|
||||||
|
uid="uid://dsi2vtxfvqdm7"
|
||||||
|
path="res://.godot/imported/mekton_bull.glb-55daae491fc640a3c58139492aec6bb6.scn"
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/characters/mektons/mekton_bull.glb"
|
||||||
|
dest_files=["res://.godot/imported/mekton_bull.glb-55daae491fc640a3c58139492aec6bb6.scn"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
nodes/root_type=""
|
||||||
|
nodes/root_name=""
|
||||||
|
nodes/root_script=null
|
||||||
|
nodes/apply_root_scale=true
|
||||||
|
nodes/root_scale=1.0
|
||||||
|
nodes/import_as_skeleton_bones=false
|
||||||
|
nodes/use_name_suffixes=true
|
||||||
|
nodes/use_node_type_suffixes=true
|
||||||
|
meshes/ensure_tangents=true
|
||||||
|
meshes/generate_lods=true
|
||||||
|
meshes/create_shadow_meshes=true
|
||||||
|
meshes/light_baking=1
|
||||||
|
meshes/lightmap_texel_size=0.2
|
||||||
|
meshes/force_disable_compression=false
|
||||||
|
skins/use_named_skins=true
|
||||||
|
animation/import=true
|
||||||
|
animation/fps=30
|
||||||
|
animation/trimming=false
|
||||||
|
animation/remove_immutable_tracks=true
|
||||||
|
animation/import_rest_as_RESET=false
|
||||||
|
import_script/path=""
|
||||||
|
materials/extract=0
|
||||||
|
materials/extract_format=0
|
||||||
|
materials/extract_path=""
|
||||||
|
_subresources={}
|
||||||
|
gltf/naming_version=2
|
||||||
|
gltf/embedded_image_handling=1
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 MiB |
@@ -0,0 +1,45 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://c5nsxhrn7kgln"
|
||||||
|
path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.s3tc.ctex"
|
||||||
|
path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.etc2.ctex"
|
||||||
|
metadata={
|
||||||
|
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||||
|
"vram_texture": true
|
||||||
|
}
|
||||||
|
generator_parameters={
|
||||||
|
"md5": "74daccd2f257ba6ddc877d0e3112b374"
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901.png"
|
||||||
|
dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.etc2.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=2
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=true
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=0
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.9 MiB |
+45
@@ -0,0 +1,45 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://ctf5kpns0edj8"
|
||||||
|
path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.s3tc.ctex"
|
||||||
|
path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.etc2.ctex"
|
||||||
|
metadata={
|
||||||
|
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||||
|
"vram_texture": true
|
||||||
|
}
|
||||||
|
generator_parameters={
|
||||||
|
"md5": "30794e134a9d90aee14b4cd127e9d1cb"
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png"
|
||||||
|
dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.etc2.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=2
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=true
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=0
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
@@ -0,0 +1,45 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://2f4nyuypxmfl"
|
||||||
|
path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.s3tc.ctex"
|
||||||
|
path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.etc2.ctex"
|
||||||
|
metadata={
|
||||||
|
"imported_formats": ["s3tc_bptc", "etc2_astc"],
|
||||||
|
"vram_texture": true
|
||||||
|
}
|
||||||
|
generator_parameters={
|
||||||
|
"md5": "a8a4c3a73bd7e809157f8fc65b8872c9"
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png"
|
||||||
|
dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.etc2.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=2
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=1
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=true
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=1
|
||||||
|
roughness/src_normal="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png"
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=0
|
||||||
@@ -1,22 +1,7 @@
|
|||||||
{
|
{
|
||||||
"latest_version": "2.4.2",
|
"latest_version": "2.4.1",
|
||||||
"minimum_app_version": "2.1.0",
|
"minimum_app_version": "2.1.0",
|
||||||
"releases": [
|
"releases": [
|
||||||
{
|
|
||||||
"version": "2.4.2",
|
|
||||||
"date": "2026-07-03",
|
|
||||||
"pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck",
|
|
||||||
"pck_size": 0,
|
|
||||||
"changelog": [
|
|
||||||
"Replaced Cleanser mechanic in Gauntlet with Ghost powerup sticky-bypass system.",
|
|
||||||
"Ghost (Invisible Mode) now lets players walk through sticky candy tiles in Gauntlet.",
|
|
||||||
"Players earn a Ghost powerup every 2 completed missions in Gauntlet.",
|
|
||||||
"Ghost powerup tiles now spawn naturally on the Gauntlet arena (15% chance).",
|
|
||||||
"Removed Cleanser HUD elements from Gauntlet overlay.",
|
|
||||||
"Bots now activate Ghost powerup when boxed in by sticky tiles.",
|
|
||||||
"Players pushed into sticky tiles while in Ghost mode are no longer slowed."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"date": "2026-06-28",
|
"date": "2026-06-28",
|
||||||
|
|||||||
Binary file not shown.
+16
-23
@@ -1,20 +1,14 @@
|
|||||||
[runnable_presets]
|
|
||||||
|
|
||||||
"Windows Desktop"="Windows Desktop"
|
|
||||||
Android="Android"
|
|
||||||
macOS="macOS"
|
|
||||||
Linux="Linux/X11"
|
|
||||||
|
|
||||||
[preset.0]
|
[preset.0]
|
||||||
|
|
||||||
name="Windows Desktop"
|
name="Windows Desktop"
|
||||||
platform="Windows Desktop"
|
platform="Windows Desktop"
|
||||||
|
runnable=true
|
||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/windows/tekton_armageddon_v2.4.2.exe"
|
export_path="build/tekton_armageddon_v2.4.1.exe"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
patch_delta_compression_level_zstd=19
|
patch_delta_compression_level_zstd=19
|
||||||
@@ -48,8 +42,8 @@ application/modify_resources=false
|
|||||||
application/icon=""
|
application/icon=""
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/file_version="2.4.2"
|
application/file_version="2.4.1"
|
||||||
application/product_version="2.4.2"
|
application/product_version="2.4.1"
|
||||||
application/company_name="DanchieGo"
|
application/company_name="DanchieGo"
|
||||||
application/product_name="Tekton Armageddon"
|
application/product_name="Tekton Armageddon"
|
||||||
application/file_description=""
|
application/file_description=""
|
||||||
@@ -80,12 +74,13 @@ Remove-Item -Recurse -Force '{temp_dir}'"
|
|||||||
|
|
||||||
name="Android"
|
name="Android"
|
||||||
platform="Android"
|
platform="Android"
|
||||||
|
runnable=true
|
||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/tekton-dash-armageddon-v.2.4.2.apk"
|
export_path="build/tekton-dash-armageddon-v.2.4.1.apk"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
patch_delta_compression_level_zstd=19
|
patch_delta_compression_level_zstd=19
|
||||||
@@ -116,7 +111,7 @@ architectures/arm64-v8a=true
|
|||||||
architectures/x86=false
|
architectures/x86=false
|
||||||
architectures/x86_64=false
|
architectures/x86_64=false
|
||||||
version/code=3
|
version/code=3
|
||||||
version/name="2.4.2"
|
version/name="2.4.1"
|
||||||
package/unique_name="com.danchiego.$genname"
|
package/unique_name="com.danchiego.$genname"
|
||||||
package/name="Tekton Dash Armageddon"
|
package/name="Tekton Dash Armageddon"
|
||||||
package/signed=true
|
package/signed=true
|
||||||
@@ -141,12 +136,11 @@ screen/support_normal=true
|
|||||||
screen/support_large=true
|
screen/support_large=true
|
||||||
screen/support_xlarge=true
|
screen/support_xlarge=true
|
||||||
screen/background_color=Color(0, 0, 0, 1)
|
screen/background_color=Color(0, 0, 0, 1)
|
||||||
splash_screen/disable_godot_boot_splash=false
|
|
||||||
splash_screen/icon=""
|
|
||||||
splash_screen/branding_image=""
|
|
||||||
splash_screen/background_color=Color(0, 0, 0, 1)
|
|
||||||
user_data_backup/allow=false
|
user_data_backup/allow=false
|
||||||
command_line/extra_args=""
|
command_line/extra_args=""
|
||||||
|
apk_expansion/enable=false
|
||||||
|
apk_expansion/SALT=""
|
||||||
|
apk_expansion/public_key=""
|
||||||
permissions/custom_permissions=PackedStringArray()
|
permissions/custom_permissions=PackedStringArray()
|
||||||
permissions/access_checkin_properties=false
|
permissions/access_checkin_properties=false
|
||||||
permissions/access_coarse_location=false
|
permissions/access_coarse_location=false
|
||||||
@@ -301,20 +295,18 @@ permissions/write_sms=false
|
|||||||
permissions/write_social_stream=false
|
permissions/write_social_stream=false
|
||||||
permissions/write_sync_settings=false
|
permissions/write_sync_settings=false
|
||||||
permissions/write_user_dictionary=false
|
permissions/write_user_dictionary=false
|
||||||
apk_expansion/enable=false
|
|
||||||
apk_expansion/SALT=""
|
|
||||||
apk_expansion/public_key=""
|
|
||||||
|
|
||||||
[preset.2]
|
[preset.2]
|
||||||
|
|
||||||
name="macOS"
|
name="macOS"
|
||||||
platform="macOS"
|
platform="macOS"
|
||||||
|
runnable=true
|
||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/tekton_armageddon_v2.4.2.zip"
|
export_path="build/tekton_armageddon_v2.4.1.zip"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
patch_delta_compression_level_zstd=19
|
patch_delta_compression_level_zstd=19
|
||||||
@@ -573,8 +565,8 @@ codesign/digest_algorithm=1
|
|||||||
codesign/identity_type=0
|
codesign/identity_type=0
|
||||||
application/modify_resources=false
|
application/modify_resources=false
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/file_version="2.4.2"
|
application/file_version="2.4.1"
|
||||||
application/product_version="2.4.2"
|
application/product_version="2.4.1"
|
||||||
application/company_name="DanchieGo"
|
application/company_name="DanchieGo"
|
||||||
application/product_name="Tekton Armageddon"
|
application/product_name="Tekton Armageddon"
|
||||||
application/file_description=""
|
application/file_description=""
|
||||||
@@ -584,12 +576,13 @@ application/trademarks=""
|
|||||||
|
|
||||||
name="Linux/X11"
|
name="Linux/X11"
|
||||||
platform="Linux"
|
platform="Linux"
|
||||||
|
runnable=true
|
||||||
dedicated_server=false
|
dedicated_server=false
|
||||||
custom_features=""
|
custom_features=""
|
||||||
export_filter="all_resources"
|
export_filter="all_resources"
|
||||||
include_filter=""
|
include_filter=""
|
||||||
exclude_filter=""
|
exclude_filter=""
|
||||||
export_path="build/linux/tekton_armageddon_v2.4.22.x86_64"
|
export_path="build/tekton_armageddon_v2.4.1.x86_64"
|
||||||
patches=PackedStringArray()
|
patches=PackedStringArray()
|
||||||
patch_delta_encoding=false
|
patch_delta_encoding=false
|
||||||
patch_delta_compression_level_zstd=19
|
patch_delta_compression_level_zstd=19
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Godot AI contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+17
-14
@@ -1,33 +1,36 @@
|
|||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Usage: patch_version.py <version> [notes]")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
version = sys.argv[1]
|
|
||||||
notes = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
||||||
|
|
||||||
with open("assets/data/version.json", "r", encoding="utf-8") as f:
|
with open("assets/data/version.json", "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
changelog = [line.strip() for line in notes.splitlines() if line.strip()]
|
# Find the 2.4.0 entry or just add 2.4.1 at the top
|
||||||
|
changelog = [
|
||||||
|
"Fixed Gauntlet map layout to remove red unpassable barrier blocks and center blocks.",
|
||||||
|
"Fixed Gauntlet mode to prevent powerups or sticky bubbles from spawning on boundary tiles or under the central cannon.",
|
||||||
|
"Center Candy Cannon now shoots actual projectiles that fly towards sticky cells and leave a VFX trail.",
|
||||||
|
"Added new VFX to the Center Candy Cannon. It now has a glowing pink tank and spinning metallic rings.",
|
||||||
|
"Fixed Gauntlet Cleanser to stack charges instead of capping at 1.",
|
||||||
|
"Cleanser instantly clears a 3x3 AoE of sticky cells and frees any players inside immediately upon activation.",
|
||||||
|
"Added VFX and SFX when purifying cells with the Cleanser (cyan burst particles).",
|
||||||
|
"Added instant visual feedback indicator for Gauntlet Cleanser using popup text when consumed.",
|
||||||
|
"Fixed Gauntlet Cleanser UI phase label layout to ensure it does not overlap with other UI elements."
|
||||||
|
]
|
||||||
|
|
||||||
new_release = {
|
new_release = {
|
||||||
"version": version,
|
"version": "2.4.1",
|
||||||
"date": date.today().isoformat(),
|
"date": "2026-06-28",
|
||||||
"pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck",
|
"pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck",
|
||||||
"pck_size": 0,
|
"pck_size": 0,
|
||||||
"changelog": changelog
|
"changelog": changelog
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove existing entry for this version if any
|
# Remove existing 2.4.1 if any
|
||||||
data["releases"] = [r for r in data["releases"] if r.get("version") != version]
|
data["releases"] = [r for r in data["releases"] if r.get("version") != "2.4.1"]
|
||||||
|
|
||||||
# Insert at top
|
# Insert at top
|
||||||
data["releases"].insert(0, new_release)
|
data["releases"].insert(0, new_release)
|
||||||
data["latest_version"] = version
|
data["latest_version"] = "2.4.1"
|
||||||
|
|
||||||
with open("assets/data/version.json", "w", encoding="utf-8") as f:
|
with open("assets/data/version.json", "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent="\t")
|
json.dump(data, f, indent="\t")
|
||||||
|
|||||||
+9
-6
@@ -17,7 +17,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
config/name="Tekton Dash Armageddon"
|
config/name="Tekton Dash Armageddon"
|
||||||
config/version="2.4.1"
|
config/version="2.4.1"
|
||||||
run/main_scene="res://scenes/ui/boot_screen.tscn"
|
run/main_scene="res://scenes/ui/boot_screen.tscn"
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||||
boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1)
|
boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1)
|
||||||
boot_splash/stretch_mode=0
|
boot_splash/stretch_mode=0
|
||||||
boot_splash/image="uid://b10e6kr508642"
|
boot_splash/image="uid://b10e6kr508642"
|
||||||
@@ -38,7 +38,7 @@ GoalManager="*res://scripts/managers/goal_manager.gd"
|
|||||||
PlayerManager="*res://scripts/managers/player_manager.gd"
|
PlayerManager="*res://scripts/managers/player_manager.gd"
|
||||||
GoalsCycleManager="*res://scripts/managers/goals_cycle_manager.gd"
|
GoalsCycleManager="*res://scripts/managers/goals_cycle_manager.gd"
|
||||||
Satori="*uid://b8vev00s34b7"
|
Satori="*uid://b8vev00s34b7"
|
||||||
SettingsManager="*res://scripts/managers/settings_manager.gd"
|
SettingsManager="*uid://c1ouaaqnn0lrc"
|
||||||
SfxManager="*res://scripts/managers/sfx_manager.gd"
|
SfxManager="*res://scripts/managers/sfx_manager.gd"
|
||||||
NameGenerator="*res://scripts/generators/name_generator.gd"
|
NameGenerator="*res://scripts/generators/name_generator.gd"
|
||||||
MusicManager="*res://scripts/managers/music_manager.gd"
|
MusicManager="*res://scripts/managers/music_manager.gd"
|
||||||
@@ -67,11 +67,8 @@ enabled=PackedStringArray("res://addons/com.heroiclabs.nakama/plugin.cfg", "res:
|
|||||||
[file_customization]
|
[file_customization]
|
||||||
|
|
||||||
folder_colors={
|
folder_colors={
|
||||||
"res://addons/": "pink",
|
|
||||||
"res://assets/": "purple",
|
"res://assets/": "purple",
|
||||||
"res://scenes/": "green",
|
"res://scenes/": "green"
|
||||||
"res://scripts/": "green",
|
|
||||||
"res://server/": "blue"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[input]
|
[input]
|
||||||
@@ -143,6 +140,12 @@ use_powerup={
|
|||||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":false,"script":null)
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
use_cleanser={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||||
|
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":2,"pressure":0.0,"pressed":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
action_grab_tekton={
|
action_grab_tekton={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://c5xk3jdg2s11m"]
|
||||||
|
|
||||||
|
[node name="MektonBullsArena" type="Node3D"]
|
||||||
|
|
||||||
|
[node name="GridMap" type="GridMap" parent="."]
|
||||||
|
|
||||||
|
[node name="Area3D" type="Area3D" parent="."]
|
||||||
@@ -47,3 +47,22 @@ theme_override_constants/outline_size = 6
|
|||||||
theme_override_fonts/font = ExtResource("1_font")
|
theme_override_fonts/font = ExtResource("1_font")
|
||||||
text = "🍬 OPEN ARENA"
|
text = "🍬 OPEN ARENA"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="CleanserHBox" type="HBoxContainer" parent="BottomContainer/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 6
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="CleanserIcon" type="TextureRect" parent="BottomContainer/VBoxContainer/CleanserHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
custom_minimum_size = Vector2(20, 20)
|
||||||
|
stretch_mode = 5
|
||||||
|
|
||||||
|
[node name="CleanserLabel" type="Label" parent="BottomContainer/VBoxContainer/CleanserHBox"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_font_sizes/font_size = 20
|
||||||
|
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||||
|
theme_override_constants/outline_size = 6
|
||||||
|
theme_override_fonts/font = ExtResource("1_font")
|
||||||
|
text = "[E] Cleanser (0)"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|||||||
+29
-4
@@ -17,6 +17,7 @@ var is_match_ended: bool = false
|
|||||||
var obstacle_manager
|
var obstacle_manager
|
||||||
var portal_mode_manager
|
var portal_mode_manager
|
||||||
var gauntlet_manager
|
var gauntlet_manager
|
||||||
|
var mekton_bulls_manager
|
||||||
var vfx_manager
|
var vfx_manager
|
||||||
|
|
||||||
# Minimal local state
|
# Minimal local state
|
||||||
@@ -258,7 +259,14 @@ func _init_managers():
|
|||||||
gauntlet_manager.name = "GauntletManager"
|
gauntlet_manager.name = "GauntletManager"
|
||||||
add_child(gauntlet_manager)
|
add_child(gauntlet_manager)
|
||||||
gauntlet_manager.initialize(self, $EnhancedGridMap)
|
gauntlet_manager.initialize(self, $EnhancedGridMap)
|
||||||
|
|
||||||
|
# Mekton Bulls manager
|
||||||
|
if LobbyManager.game_mode == "Mekton Bulls":
|
||||||
|
mekton_bulls_manager = load("res://scripts/managers/mekton_bulls_manager.gd").new()
|
||||||
|
mekton_bulls_manager.name = "MektonBullsManager"
|
||||||
|
add_child(mekton_bulls_manager)
|
||||||
|
mekton_bulls_manager.initialize(self, $EnhancedGridMap)
|
||||||
|
|
||||||
# Screen shake manager for impact feedback
|
# Screen shake manager for impact feedback
|
||||||
screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new()
|
screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new()
|
||||||
screen_shake_manager.name = "ScreenShakeManager"
|
screen_shake_manager.name = "ScreenShakeManager"
|
||||||
@@ -623,6 +631,8 @@ func _setup_host_game():
|
|||||||
portal_mode_manager.setup_arena_locally()
|
portal_mode_manager.setup_arena_locally()
|
||||||
elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||||
gauntlet_manager._setup_arena()
|
gauntlet_manager._setup_arena()
|
||||||
|
elif LobbyManager.game_mode == "Mekton Bulls" and mekton_bulls_manager:
|
||||||
|
mekton_bulls_manager._setup_arena()
|
||||||
else:
|
else:
|
||||||
# Randomize grid first to ensure Floor 0 is walkable for pre-calculation
|
# Randomize grid first to ensure Floor 0 is walkable for pre-calculation
|
||||||
randomize_game_grid()
|
randomize_game_grid()
|
||||||
@@ -728,10 +738,20 @@ func _setup_client_game():
|
|||||||
# Initialize arena locally for Tekton Doors
|
# Initialize arena locally for Tekton Doors
|
||||||
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager:
|
||||||
portal_mode_manager.setup_arena_locally()
|
portal_mode_manager.setup_arena_locally()
|
||||||
|
|
||||||
# Initialize arena locally for Candy Pump Survival
|
# Special initialization for Gauntlet mode
|
||||||
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager:
|
||||||
gauntlet_manager._apply_arena_setup()
|
gauntlet_manager._apply_arena_setup()
|
||||||
|
|
||||||
|
if LobbyManager.game_mode == "Mekton Bulls" and mekton_bulls_manager:
|
||||||
|
mekton_bulls_manager.hud_node = load("res://scenes/ui/mekton_bulls_hud.tscn").instantiate()
|
||||||
|
mekton_bulls_manager.hud_node.name = "MektonBullsHUD"
|
||||||
|
add_child(mekton_bulls_manager.hud_node)
|
||||||
|
if mekton_bulls_manager.hud_node.has_method("initialize"):
|
||||||
|
mekton_bulls_manager.hud_node.initialize(mekton_bulls_manager)
|
||||||
|
if mekton_bulls_manager.hud_node.has_method("set_local_player"):
|
||||||
|
mekton_bulls_manager.hud_node.set_local_player(multiplayer.get_unique_id())
|
||||||
|
mekton_bulls_manager._apply_arena_setup()
|
||||||
|
|
||||||
# Ensure local player setup (UI, controls) is verified
|
# Ensure local player setup (UI, controls) is verified
|
||||||
var player_character = get_node_or_null(str(my_id))
|
var player_character = get_node_or_null(str(my_id))
|
||||||
@@ -876,10 +896,15 @@ func _start_game():
|
|||||||
elif LobbyManager.game_mode == "Candy Pump Survival":
|
elif LobbyManager.game_mode == "Candy Pump Survival":
|
||||||
if gauntlet_manager:
|
if gauntlet_manager:
|
||||||
gauntlet_manager.start_game_mode()
|
gauntlet_manager.start_game_mode()
|
||||||
|
|
||||||
if goals_cycle_manager:
|
if goals_cycle_manager:
|
||||||
var match_duration = LobbyManager.get_match_duration()
|
var match_duration = LobbyManager.get_match_duration()
|
||||||
goals_cycle_manager.start_match(float(match_duration), true) # Enable cycles for 3x3 pattern missions
|
goals_cycle_manager.start_match(float(match_duration), true) # Enable cycles for 3x3 pattern missions
|
||||||
|
elif LobbyManager.game_mode == "Mekton Bulls":
|
||||||
|
if mekton_bulls_manager:
|
||||||
|
mekton_bulls_manager.start_game_mode()
|
||||||
|
if goals_cycle_manager:
|
||||||
|
var match_duration = LobbyManager.get_match_duration()
|
||||||
|
goals_cycle_manager.start_match(float(match_duration))
|
||||||
elif goals_cycle_manager:
|
elif goals_cycle_manager:
|
||||||
var match_duration = LobbyManager.get_match_duration()
|
var match_duration = LobbyManager.get_match_duration()
|
||||||
goals_cycle_manager.start_match(float(match_duration))
|
goals_cycle_manager.start_match(float(match_duration))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[gd_scene load_steps=5 format=3 uid="uid://bull1234abcd"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://bullscript1" path="res://scripts/npcs/mekton_bull.gd" id="1_bull"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://df7h7y7y7y7y7" path="res://scenes/static_tekton_mesh.tscn" id="2_mesh"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_bull"]
|
||||||
|
size = Vector3(1.5, 2.0, 1.5)
|
||||||
|
|
||||||
|
[node name="MektonBull" type="Area3D" groups=["MektonBulls"]]
|
||||||
|
collision_layer = 4
|
||||||
|
collision_mask = 2
|
||||||
|
script = ExtResource("1_bull")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 0)
|
||||||
|
shape = SubResource("BoxShape3D_bull")
|
||||||
|
|
||||||
|
[node name="Visuals" type="Node3D" parent="."]
|
||||||
|
|
||||||
|
[node name="Mesh" parent="Visuals" instance=ExtResource("2_mesh")]
|
||||||
|
transform = Transform3D(0.4, 0, 0, 0, 0.4, 0, 0, 0, 0.4, 0, 0, 0)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
[gd_scene format=3 uid="uid://bullhud"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://bullhudscript" path="res://scripts/ui/mekton_bulls_hud.gd" id="1_hud"]
|
||||||
|
|
||||||
|
[node name="MektonBullsHUD" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1_hud")
|
||||||
|
|
||||||
|
[node name="BullTracker" type="Control" parent="."]
|
||||||
|
anchors_preset = 0
|
||||||
|
|
||||||
|
[node name="PowerCounters" type="Control" parent="."]
|
||||||
|
anchors_preset = 0
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 2
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_top = -60.0
|
||||||
|
offset_right = 300.0
|
||||||
|
grow_vertical = 0
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="PowerCounters"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
text = "Freeze: 0 | Knock: 0"
|
||||||
|
vertical_alignment = 1
|
||||||
|
|
||||||
|
[node name="PowerPicker" type="PanelContainer" parent="."]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 8
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -100.0
|
||||||
|
offset_top = -50.0
|
||||||
|
offset_right = 100.0
|
||||||
|
offset_bottom = 50.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="HBoxContainer" type="HBoxContainer" parent="PowerPicker"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="FreezeBtn" type="Button" parent="PowerPicker/HBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Freeze"
|
||||||
|
|
||||||
|
[node name="KnockBtn" type="Button" parent="PowerPicker/HBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
text = "Knock"
|
||||||
|
|
||||||
|
[node name="PlacementPanel" type="PanelContainer" parent="."]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 8
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -200.0
|
||||||
|
offset_top = -150.0
|
||||||
|
offset_right = 200.0
|
||||||
|
offset_bottom = 150.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="VBoxContainer" type="VBoxContainer" parent="PlacementPanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="PlacementPanel/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Match Placements"
|
||||||
|
horizontal_alignment = 1
|
||||||
|
|
||||||
|
[node name="List" type="VBoxContainer" parent="PlacementPanel/VBoxContainer"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
|
||||||
|
[connection signal="pressed" from="PowerPicker/HBoxContainer/FreezeBtn" to="." method="_on_freeze_btn_pressed"]
|
||||||
|
[connection signal="pressed" from="PowerPicker/HBoxContainer/KnockBtn" to="." method="_on_knock_btn_pressed"]
|
||||||
+22
-19
@@ -210,9 +210,9 @@ func _run_ai_tick():
|
|||||||
print("[BotController] Action Taken: Attack Pursuit")
|
print("[BotController] Action Taken: Attack Pursuit")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Priority 0.5: Gauntlet (#075) — use Ghost powerup if boxed in
|
# Priority 0.5: Gauntlet (#075) — burn Cleanser if boxed in
|
||||||
if await _try_activate_ghost():
|
if await _try_activate_cleanser():
|
||||||
print("[BotController] Action Taken: Ghost (trapped)")
|
print("[BotController] Action Taken: Cleanser (trapped)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Priority 1: Tekton Management (Grab Tekton if full boost, or spawn if carrying)
|
# Priority 1: Tekton Management (Grab Tekton if full boost, or spawn if carrying)
|
||||||
@@ -260,24 +260,27 @@ func _run_ai_tick():
|
|||||||
return
|
return
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Gauntlet (#075) — Ghost Powerup + Sticky Avoidance wiring
|
# Gauntlet (#075) — Cleanser + Sticky Avoidance wiring
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
func _try_activate_ghost() -> bool:
|
func _try_activate_cleanser() -> bool:
|
||||||
"""Activate Ghost powerup when the planner reports imminent danger.
|
"""Activate Cleanser when the planner reports imminent danger.
|
||||||
|
|
||||||
Uses the existing SpecialTilesManager to activate the held ghost powerup.
|
Server-authoritative RPC; we only request it. Returns true if the request
|
||||||
Returns true if activation was triggered."""
|
was sent successfully (not a guarantee it landed on a sticky cell)."""
|
||||||
if not strategic_planner or not strategic_planner.is_gauntlet_mode():
|
if not strategic_planner or not strategic_planner.is_gauntlet_mode():
|
||||||
return false
|
return false
|
||||||
if not strategic_planner.should_activate_ghost_now():
|
if not strategic_planner.should_activate_cleanser_now():
|
||||||
return false
|
return false
|
||||||
var stm = actor.get_node_or_null("SpecialTilesManager")
|
var gm = strategic_planner._get_gauntlet_manager()
|
||||||
if not stm:
|
if not gm:
|
||||||
return false
|
return false
|
||||||
if stm.has_method("activate_effect"):
|
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||||
stm.activate_effect(stm.SpecialEffect.INVISIBLE_MODE)
|
if pid == null or pid < 0:
|
||||||
print("[BotController] %s activated Ghost powerup (trapped)" % actor.name)
|
return false
|
||||||
|
if gm.has_method("rpc_activate_cleanser"):
|
||||||
|
gm.rpc_activate_cleanser(pid)
|
||||||
|
print("[BotController] %s requested Cleanser activation (trapped)" % actor.name)
|
||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@@ -289,14 +292,14 @@ func _on_step_onto_unsafe() -> bool:
|
|||||||
var here = actor.current_position if "current_position" in actor else Vector2i(-1, -1)
|
var here = actor.current_position if "current_position" in actor else Vector2i(-1, -1)
|
||||||
if here == Vector2i(-1, -1):
|
if here == Vector2i(-1, -1):
|
||||||
return false
|
return false
|
||||||
# Post-move guard: if we somehow landed on a sticky without ghost active,
|
# Post-move guard: if we somehow landed on a sticky without cleanser active,
|
||||||
# burn Ghost powerup to phase through next tick.
|
# burn Cleanser to clear ourselves out next tick.
|
||||||
if strategic_planner.is_gauntlet_mode() and strategic_planner._is_overlay_unsafe(here):
|
if strategic_planner.is_gauntlet_mode() and strategic_planner._is_overlay_unsafe(here):
|
||||||
if not strategic_planner._is_bot_ghost_active():
|
if not strategic_planner._is_bot_cleanser_active():
|
||||||
var gm = strategic_planner._get_gauntlet_manager()
|
var gm = strategic_planner._get_gauntlet_manager()
|
||||||
if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(here):
|
if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(here):
|
||||||
print("[BotController] %s stepped onto sticky at %s — activating Ghost" % [actor.name, here])
|
print("[BotController] %s stepped onto sticky at %s — burning Cleanser" % [actor.name, here])
|
||||||
return _try_activate_ghost()
|
return _try_activate_cleanser()
|
||||||
return false
|
return false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const GOAL_TILES = [7, 8, 9, 10] # Heart, Diamond, Star, Coin
|
|||||||
const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles
|
const HOLO_TILES = [11, 12, 13, 14] # Power-up holo tiles
|
||||||
|
|
||||||
# Gauntlet overlay layer (v2 ground-growth model — sticky/telegraph on layer 2).
|
# Gauntlet overlay layer (v2 ground-growth model — sticky/telegraph on layer 2).
|
||||||
# Bots must avoid these cells or use Ghost mode to cross.
|
# Bots must avoid these cells or burn a Cleanser charge to cross.
|
||||||
const GAUNTLET_OVERLAY_LAYER: int = 2
|
const GAUNTLET_OVERLAY_LAYER: int = 2
|
||||||
const TILE_STICKY: int = 17
|
const TILE_STICKY: int = 17
|
||||||
const TILE_TELEGRAPH: int = 18
|
const TILE_TELEGRAPH: int = 18
|
||||||
@@ -61,16 +61,23 @@ func _get_gauntlet_manager() -> Node:
|
|||||||
return gm2
|
return gm2
|
||||||
return null
|
return null
|
||||||
|
|
||||||
func _bot_has_ghost_powerup() -> bool:
|
func _bot_has_cleanser_charge() -> bool:
|
||||||
"""Check if the bot has a ghost powerup in its SpecialTilesManager inventory."""
|
var gm = _get_gauntlet_manager()
|
||||||
var stm = actor.get_node_or_null("SpecialTilesManager")
|
if not gm or not "player_cleansers" in gm:
|
||||||
if not stm:
|
|
||||||
return false
|
return false
|
||||||
return stm.inventory.get(stm.SpecialEffect.INVISIBLE_MODE, false)
|
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||||
|
if pid == null or pid < 0:
|
||||||
|
return false
|
||||||
|
return gm.player_cleansers.get(pid, 0) > 0
|
||||||
|
|
||||||
func _is_bot_ghost_active() -> bool:
|
func _is_bot_cleanser_active() -> bool:
|
||||||
"""Check if the bot is currently in ghost (invisible) mode."""
|
var gm = _get_gauntlet_manager()
|
||||||
return actor.get("is_invisible") == true
|
if not gm:
|
||||||
|
return false
|
||||||
|
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||||
|
if pid == null or pid < 0:
|
||||||
|
return false
|
||||||
|
return gm.is_cleanser_active(pid)
|
||||||
|
|
||||||
func _is_overlay_unsafe(pos: Vector2i) -> bool:
|
func _is_overlay_unsafe(pos: Vector2i) -> bool:
|
||||||
"""True if the cell carries a sticky or telegraphed overlay on layer 2."""
|
"""True if the cell carries a sticky or telegraphed overlay on layer 2."""
|
||||||
@@ -81,10 +88,10 @@ func _is_overlay_unsafe(pos: Vector2i) -> bool:
|
|||||||
|
|
||||||
func _is_cell_unsafe_in_gauntlet(pos: Vector2i) -> bool:
|
func _is_cell_unsafe_in_gauntlet(pos: Vector2i) -> bool:
|
||||||
"""Cell is unsafe in Gauntlet if it's sticky/telegraphed — unless the bot's
|
"""Cell is unsafe in Gauntlet if it's sticky/telegraphed — unless the bot's
|
||||||
Ghost mode is active (grants sticky bypass)."""
|
Cleanser is active (grants temporary immunity)."""
|
||||||
if not is_gauntlet_mode():
|
if not is_gauntlet_mode():
|
||||||
return false
|
return false
|
||||||
if _is_bot_ghost_active():
|
if _is_bot_cleanser_active():
|
||||||
return false
|
return false
|
||||||
var gm = _get_gauntlet_manager()
|
var gm = _get_gauntlet_manager()
|
||||||
if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(pos):
|
if gm and gm.has_method("is_sticky_cell") and gm.is_sticky_cell(pos):
|
||||||
@@ -99,13 +106,13 @@ func _count_unsafe_neighbors(pos: Vector2i) -> int:
|
|||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
func should_activate_ghost_now() -> bool:
|
func should_activate_cleanser_now() -> bool:
|
||||||
"""True if the bot is boxed in / about to be sealed and should use Ghost powerup."""
|
"""True if the bot is boxed in / about to be sealed and should burn Cleanser."""
|
||||||
if not is_gauntlet_mode():
|
if not is_gauntlet_mode():
|
||||||
return false
|
return false
|
||||||
if not _bot_has_ghost_powerup():
|
if not _bot_has_cleanser_charge():
|
||||||
return false
|
return false
|
||||||
if _is_bot_ghost_active():
|
if _is_bot_cleanser_active():
|
||||||
return false
|
return false
|
||||||
var here = actor.current_position if actor and "current_position" in actor else Vector2i(-1, -1)
|
var here = actor.current_position if actor and "current_position" in actor else Vector2i(-1, -1)
|
||||||
if here == Vector2i(-1, -1):
|
if here == Vector2i(-1, -1):
|
||||||
@@ -127,6 +134,56 @@ func _normalize_tile(tile: int) -> int:
|
|||||||
return tile - 4 # 11->7, 12->8, etc.
|
return tile - 4 # 11->7, 12->8, etc.
|
||||||
return tile
|
return tile
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mekton Bulls mode helpers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func is_mekton_bulls_mode() -> bool:
|
||||||
|
return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.MEKTON_BULLS)
|
||||||
|
|
||||||
|
func _get_mekton_bulls_manager() -> Node:
|
||||||
|
if gauntlet_manager_override and is_instance_valid(gauntlet_manager_override):
|
||||||
|
return gauntlet_manager_override
|
||||||
|
|
||||||
|
var current = actor
|
||||||
|
while current != null:
|
||||||
|
var bm = current.get_node_or_null("MektonBullsManager")
|
||||||
|
if bm: return bm
|
||||||
|
current = current.get_parent()
|
||||||
|
|
||||||
|
var root = actor.get_tree().root
|
||||||
|
var main = root.get_node_or_null("Main")
|
||||||
|
if main:
|
||||||
|
return main.get_node_or_null("MektonBullsManager")
|
||||||
|
return null
|
||||||
|
|
||||||
|
func _get_active_bulls() -> Array:
|
||||||
|
return actor.get_tree().get_nodes_in_group("MektonBulls")
|
||||||
|
|
||||||
|
func _is_cell_unsafe_in_mekton_bulls(pos: Vector2i) -> bool:
|
||||||
|
"""Cell is unsafe if it's WATER, or if it's on the boundary (soon to be flooded)."""
|
||||||
|
if not is_mekton_bulls_mode(): return false
|
||||||
|
var bm = _get_mekton_bulls_manager()
|
||||||
|
if not bm: return false
|
||||||
|
|
||||||
|
# Check if water
|
||||||
|
var tile = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
||||||
|
if tile == 24: # TILE_WATER
|
||||||
|
return true
|
||||||
|
|
||||||
|
if bm.has_method("_is_boundary") and bm._is_boundary(pos):
|
||||||
|
return true
|
||||||
|
|
||||||
|
# Bull proximity
|
||||||
|
var bulls = _get_active_bulls()
|
||||||
|
for b in bulls:
|
||||||
|
var b_pos = enhanced_gridmap.local_to_map(b.position)
|
||||||
|
# If cell is adjacent to the bull, it's unsafe.
|
||||||
|
if abs(b_pos.x - pos.x) <= 1 and abs(b_pos.z - pos.y) <= 1:
|
||||||
|
return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Goal Analysis
|
# Goal Analysis
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -338,14 +395,28 @@ func find_best_tile_to_grab() -> Dictionary:
|
|||||||
|
|
||||||
func find_nearest_tile_of_type(tile_types: Array) -> Vector2i:
|
func find_nearest_tile_of_type(tile_types: Array) -> Vector2i:
|
||||||
"""Find nearest tile matching any type in array using optimized spiral search."""
|
"""Find nearest tile matching any type in array using optimized spiral search."""
|
||||||
var current_pos = actor.current_position
|
|
||||||
|
|
||||||
if not enhanced_gridmap:
|
if not enhanced_gridmap:
|
||||||
return Vector2i(-1, -1)
|
return Vector2i(-1, -1)
|
||||||
|
|
||||||
|
if is_mekton_bulls_mode():
|
||||||
|
# Return the nearest uncollected tile from our blueprint
|
||||||
|
var bm = _get_mekton_bulls_manager()
|
||||||
|
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||||
|
if bm and pid != null and bm.player_blueprints.has(pid):
|
||||||
|
var bp = bm.player_blueprints[pid]
|
||||||
|
var best_tile = Vector2i(-1, -1)
|
||||||
|
var best_dist = INF
|
||||||
|
for c in bp.cells:
|
||||||
|
if enhanced_gridmap.get_cell_item(Vector3i(c.x, 0, c.y)) == bp.color:
|
||||||
|
var dist = actor.current_position.distance_to(c)
|
||||||
|
if dist < best_dist and _is_valid_move_target(c, true):
|
||||||
|
best_dist = dist
|
||||||
|
best_tile = c
|
||||||
|
if best_tile != Vector2i(-1, -1):
|
||||||
|
return best_tile
|
||||||
|
|
||||||
|
var current_pos = actor.current_position
|
||||||
# Optimization: Start check at simple radius
|
# Optimization: Start check at simple radius
|
||||||
# If we find something in the spiral, it is guaranteed to be one of the nearest (by Chebyshev distance logic broadly, or just good enough)
|
|
||||||
|
|
||||||
var max_radius = 25 # Limit search range to prevent full map scans on huge maps
|
var max_radius = 25 # Limit search range to prevent full map scans on huge maps
|
||||||
if OS.has_feature("mobile"):
|
if OS.has_feature("mobile"):
|
||||||
max_radius = 15 # Stricter limit on mobile
|
max_radius = 15 # Stricter limit on mobile
|
||||||
@@ -431,10 +502,43 @@ func find_nearest_roaming_tekton() -> Node3D:
|
|||||||
# Movement Strategy
|
# Movement Strategy
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
func _should_use_freeze() -> bool:
|
||||||
|
if not is_mekton_bulls_mode(): return false
|
||||||
|
var bm = _get_mekton_bulls_manager()
|
||||||
|
if not bm: return false
|
||||||
|
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||||
|
if not bm.player_powers.has(pid) or bm.player_powers[pid]["FREEZE"] <= 0: return false
|
||||||
|
|
||||||
|
var bulls = _get_active_bulls()
|
||||||
|
var bot_pos = enhanced_gridmap.local_to_map(actor.position)
|
||||||
|
for b in bulls:
|
||||||
|
var b_pos = enhanced_gridmap.local_to_map(b.position)
|
||||||
|
if abs(bot_pos.x - b_pos.x) <= 3 and abs(bot_pos.z - b_pos.y) <= 3:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
func find_optimal_move_target() -> Vector2i:
|
func find_optimal_move_target() -> Vector2i:
|
||||||
"""Calculate the best position to move towards."""
|
"""Core decision logic. Evaluates sabotaging vs making progress."""
|
||||||
var main = actor.get_tree().get_root().get_node_or_null("Main")
|
var main = actor.get_tree().get_root().get_node_or_null("Main")
|
||||||
var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
|
if is_mekton_bulls_mode():
|
||||||
|
# In Mekton Bulls, use powers if viable.
|
||||||
|
if _should_use_freeze():
|
||||||
|
var bm = _get_mekton_bulls_manager()
|
||||||
|
var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||||
|
bm.try_use_freeze.rpc_id(1) # Try emitting to server
|
||||||
|
|
||||||
|
# Knock another nearby player
|
||||||
|
var mb_pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int()
|
||||||
|
var mb_bm = _get_mekton_bulls_manager()
|
||||||
|
if mb_bm and mb_bm.player_powers.has(mb_pid) and mb_bm.player_powers[mb_pid]["KNOCK"] > 0:
|
||||||
|
var opps = _get_opponents()
|
||||||
|
for op in opps:
|
||||||
|
var dist = actor.position.distance_to(op.position)
|
||||||
|
if dist < enhanced_gridmap.cell_size.x * 2.0:
|
||||||
|
mb_bm.try_use_knock.rpc_id(1, op.name.to_int(), actor.position.direction_to(op.position).normalized())
|
||||||
|
break
|
||||||
|
|
||||||
|
var is_sng = LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
|
||||||
var gc_manager = main.get_node_or_null("GoalsCycleManager") if main else null
|
var gc_manager = main.get_node_or_null("GoalsCycleManager") if main else null
|
||||||
var time_left = gc_manager.get_global_time_remaining() if gc_manager else 999.0
|
var time_left = gc_manager.get_global_time_remaining() if gc_manager else 999.0
|
||||||
var is_match_running = gc_manager.is_match_running() if gc_manager else false
|
var is_match_running = gc_manager.is_match_running() if gc_manager else false
|
||||||
@@ -595,6 +699,11 @@ func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool:
|
|||||||
if not enhanced_gridmap or not enhanced_gridmap.is_position_valid(pos):
|
if not enhanced_gridmap or not enhanced_gridmap.is_position_valid(pos):
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
if is_mekton_bulls_mode():
|
||||||
|
# Do not move into WATER or the boundary
|
||||||
|
if _is_cell_unsafe_in_mekton_bulls(pos):
|
||||||
|
return false
|
||||||
|
|
||||||
# Check Floor 0 (Ground/Walls)
|
# Check Floor 0 (Ground/Walls)
|
||||||
var floor_item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
var floor_item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
||||||
if floor_item == -1 or floor_item in enhanced_gridmap.non_walkable_items:
|
if floor_item == -1 or floor_item in enhanced_gridmap.non_walkable_items:
|
||||||
@@ -616,7 +725,7 @@ func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool:
|
|||||||
# Gauntlet mode (#075): reject cells that are sticky or telegraphed —
|
# Gauntlet mode (#075): reject cells that are sticky or telegraphed —
|
||||||
# stepping onto them either traps the bot or strands it within 1s.
|
# stepping onto them either traps the bot or strands it within 1s.
|
||||||
# Safety applies even when ignore_players is true (a sticky cell is unsafe
|
# Safety applies even when ignore_players is true (a sticky cell is unsafe
|
||||||
# regardless of whether another player is on it). Ghost-active bots are
|
# regardless of whether another player is on it). Cleanser-active bots are
|
||||||
# exempt via the helper.
|
# exempt via the helper.
|
||||||
if _is_cell_unsafe_in_gauntlet(pos):
|
if _is_cell_unsafe_in_gauntlet(pos):
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ enum Mode {
|
|||||||
FREEMODE = 0,
|
FREEMODE = 0,
|
||||||
STOP_N_GO = 1,
|
STOP_N_GO = 1,
|
||||||
TEKTON_DOORS = 2,
|
TEKTON_DOORS = 2,
|
||||||
GAUNTLET = 3
|
GAUNTLET = 3,
|
||||||
|
MEKTON_BULLS = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
static func from_string(mode: String) -> Mode:
|
static func from_string(mode: String) -> Mode:
|
||||||
@@ -18,6 +19,8 @@ static func from_string(mode: String) -> Mode:
|
|||||||
return Mode.TEKTON_DOORS
|
return Mode.TEKTON_DOORS
|
||||||
"Candy Pump Survival":
|
"Candy Pump Survival":
|
||||||
return Mode.GAUNTLET
|
return Mode.GAUNTLET
|
||||||
|
"Mekton Bulls":
|
||||||
|
return Mode.MEKTON_BULLS
|
||||||
_:
|
_:
|
||||||
return Mode.FREEMODE
|
return Mode.FREEMODE
|
||||||
|
|
||||||
@@ -31,11 +34,13 @@ static func mode_to_string(mode: Mode) -> String:
|
|||||||
return "Tekton Doors"
|
return "Tekton Doors"
|
||||||
Mode.GAUNTLET:
|
Mode.GAUNTLET:
|
||||||
return "Candy Pump Survival"
|
return "Candy Pump Survival"
|
||||||
|
Mode.MEKTON_BULLS:
|
||||||
|
return "Mekton Bulls"
|
||||||
_:
|
_:
|
||||||
return "Freemode"
|
return "Freemode"
|
||||||
|
|
||||||
static func is_restricted(mode: Mode) -> bool:
|
static func is_restricted(mode: Mode) -> bool:
|
||||||
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET
|
return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET or mode == Mode.MEKTON_BULLS
|
||||||
|
|
||||||
static func get_all_modes() -> Array[String]:
|
static func get_all_modes() -> Array[String]:
|
||||||
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival"]
|
return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival", "Mekton Bulls"]
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
|
|
||||||
const SKIP_DIRS := [".git", ".godot", "addons"]
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
|
||||||
var errors: Array[String] = []
|
|
||||||
_scan_dir("res://", errors)
|
|
||||||
if errors.is_empty():
|
|
||||||
print("Lint passed: no GDScript syntax errors found")
|
|
||||||
quit(0)
|
|
||||||
else:
|
|
||||||
for e in errors:
|
|
||||||
printerr(e)
|
|
||||||
printerr("Lint failed: %d file(s) with syntax errors" % errors.size())
|
|
||||||
quit(1)
|
|
||||||
|
|
||||||
func _scan_dir(path: String, errors: Array[String]) -> void:
|
|
||||||
var dir := DirAccess.open(path)
|
|
||||||
if dir == null:
|
|
||||||
return
|
|
||||||
dir.list_dir_begin()
|
|
||||||
var entry := dir.get_next()
|
|
||||||
while entry != "":
|
|
||||||
if entry == "." or entry == "..":
|
|
||||||
entry = dir.get_next()
|
|
||||||
continue
|
|
||||||
var full_path := path.path_join(entry)
|
|
||||||
if dir.current_is_dir():
|
|
||||||
if not SKIP_DIRS.has(entry):
|
|
||||||
_scan_dir(full_path, errors)
|
|
||||||
elif entry.ends_with(".gd"):
|
|
||||||
var script := GDScript.new()
|
|
||||||
script.source_code = FileAccess.get_file_as_string(full_path)
|
|
||||||
if script.reload() != OK:
|
|
||||||
errors.append("%s: syntax error" % full_path)
|
|
||||||
entry = dir.get_next()
|
|
||||||
@@ -7,7 +7,7 @@ class_name GauntletManager
|
|||||||
signal phase_changed(phase_index: int, phase_name: String)
|
signal phase_changed(phase_index: int, phase_name: String)
|
||||||
signal growth_tick(cells: Array)
|
signal growth_tick(cells: Array)
|
||||||
signal player_trapped(player_id: int)
|
signal player_trapped(player_id: int)
|
||||||
signal ghost_granted(player_id: int)
|
signal cleanser_granted(player_id: int)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Constants
|
# Constants
|
||||||
@@ -31,9 +31,10 @@ enum CellState {
|
|||||||
STICKY, # Covered in sticky candy, blocks + traps
|
STICKY, # Covered in sticky candy, blocks + traps
|
||||||
BUBBLE_GROWING, # Candy bubble growing, not yet exploded
|
BUBBLE_GROWING, # Candy bubble growing, not yet exploded
|
||||||
BLOCKED, # NPC zone or permanent obstacle
|
BLOCKED, # NPC zone or permanent obstacle
|
||||||
|
CLEANSED, # Recently cleaned by Cleanser (temp protection)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cells temporarily protected after Ghost-clearing (not used — kept for compat).
|
# Cells temporarily protected after a Cleanser pass (Vector2i -> time remaining).
|
||||||
var cleansed_cells: Dictionary = {}
|
var cleansed_cells: Dictionary = {}
|
||||||
const CLEANSED_PROTECTION_TIME: float = 5.0
|
const CLEANSED_PROTECTION_TIME: float = 5.0
|
||||||
|
|
||||||
@@ -145,10 +146,15 @@ const SMACK_COOLDOWN: float = 8.0
|
|||||||
const SMACK_CHARGE_WINDOW: float = 3.0
|
const SMACK_CHARGE_WINDOW: float = 3.0
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Ghost Reward Tracking (replaces Cleanser)
|
# Cleanser Tracking
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
var player_mission_completions: Dictionary = {} # player_id → int
|
var player_mission_completions: Dictionary = {} # player_id → int
|
||||||
|
var player_cleansers: Dictionary = {} # player_id → int (0 or 1)
|
||||||
|
var cleanser_active: Dictionary = {} # player_id → true when immunity active
|
||||||
|
var cleanser_cells_left: Dictionary = {} # player_id → int (cells remaining)
|
||||||
|
const CLEANSER_MAX_CELLS: int = 5
|
||||||
|
const CLEANSER_ACTIVATION_DELAY: float = 0.3
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Trapped Players
|
# Trapped Players
|
||||||
@@ -183,6 +189,9 @@ var pump_instance: Node3D = null
|
|||||||
# HUD
|
# HUD
|
||||||
var hud_layer: CanvasLayer
|
var hud_layer: CanvasLayer
|
||||||
var phase_label: Label
|
var phase_label: Label
|
||||||
|
var cleanser_label: Label
|
||||||
|
var cleanser_icon: TextureRect
|
||||||
|
var cleanser_count: int = 0
|
||||||
var slowmo_label: Label
|
var slowmo_label: Label
|
||||||
var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn")
|
var _gauntlet_hud_scene: PackedScene = preload("res://scenes/gauntlet_hud.tscn")
|
||||||
|
|
||||||
@@ -275,6 +284,10 @@ func _process(delta: float) -> void:
|
|||||||
elif not multiplayer.is_server():
|
elif not multiplayer.is_server():
|
||||||
player.sync_modulate(Color.WHITE)
|
player.sync_modulate(Color.WHITE)
|
||||||
|
|
||||||
|
# Cleanser input (local player only)
|
||||||
|
if Input.is_action_just_pressed("use_cleanser"):
|
||||||
|
_try_use_cleanser()
|
||||||
|
|
||||||
# Slow-mo timer (all peers for visual consistency)
|
# Slow-mo timer (all peers for visual consistency)
|
||||||
if slowmo_active:
|
if slowmo_active:
|
||||||
slowmo_timer -= delta
|
slowmo_timer -= delta
|
||||||
@@ -327,12 +340,9 @@ func _start_phase(phase: Phase) -> void:
|
|||||||
var phase_name = _phase_to_string(phase)
|
var phase_name = _phase_to_string(phase)
|
||||||
print("[Gauntlet] Phase changed to: ", phase_name)
|
print("[Gauntlet] Phase changed to: ", phase_name)
|
||||||
|
|
||||||
if _can_rpc() and multiplayer.is_server():
|
if _can_rpc():
|
||||||
rpc("sync_phase", int(phase), phase_name)
|
rpc("sync_phase", int(phase), phase_name)
|
||||||
|
|
||||||
# Update phase explicitly with setup_arena
|
|
||||||
_shrink_arena()
|
|
||||||
|
|
||||||
emit_signal("phase_changed", int(phase), phase_name)
|
emit_signal("phase_changed", int(phase), phase_name)
|
||||||
|
|
||||||
func _phase_to_string(phase: Phase) -> String:
|
func _phase_to_string(phase: Phase) -> String:
|
||||||
@@ -351,14 +361,6 @@ func sync_phase(phase_index: int, phase_name: String) -> void:
|
|||||||
if not is_active:
|
if not is_active:
|
||||||
activate_client_side()
|
activate_client_side()
|
||||||
current_phase = phase_index as Phase
|
current_phase = phase_index as Phase
|
||||||
if not multiplayer.is_server():
|
|
||||||
var bounds = get_arena_bounds()
|
|
||||||
for x in range(ARENA_COLUMNS):
|
|
||||||
for z in range(ARENA_ROWS):
|
|
||||||
var pos = Vector2i(x, z)
|
|
||||||
if pos.x <= bounds.min or pos.x >= bounds.max or pos.y <= bounds.min or pos.y >= bounds.max:
|
|
||||||
if not sticky_cells.has(pos):
|
|
||||||
sticky_cells[pos] = true
|
|
||||||
_update_hud_phase(phase_name)
|
_update_hud_phase(phase_name)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -417,12 +419,11 @@ func _apply_arena_setup() -> void:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Boundary walls: perimeter (row 0, row 19, col 0, col 19)
|
# Boundary walls: perimeter (row 0, row 19, col 0, col 19)
|
||||||
if pos.x <= 0 or pos.x >= 19 or pos.y <= 0 or pos.y >= 19:
|
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
|
||||||
# Also make border walls visually walkable floors instead of red blocks
|
# Also make border walls visually walkable floors instead of red blocks
|
||||||
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||||
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||||
gridmap.set_cell_item(Vector3i(x, 2, z), TILE_STICKY)
|
gridmap.set_cell_item(Vector3i(x, 2, z), -1)
|
||||||
sticky_cells[pos] = true
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Interior: walkable floor
|
# Interior: walkable floor
|
||||||
@@ -502,39 +503,39 @@ func _spawn_mission_tiles() -> void:
|
|||||||
# Goal items: Heart(7), Diamond(8), Star(9), Coin(10)
|
# Goal items: Heart(7), Diamond(8), Star(9), Coin(10)
|
||||||
var goal_items = [7, 8, 9, 10]
|
var goal_items = [7, 8, 9, 10]
|
||||||
var tiles_spawned: int = 0
|
var tiles_spawned: int = 0
|
||||||
var main = get_node_or_null("/root/Main")
|
|
||||||
|
|
||||||
for x in range(ARENA_COLUMNS):
|
for x in range(ARENA_COLUMNS):
|
||||||
for z in range(ARENA_ROWS):
|
for z in range(ARENA_ROWS):
|
||||||
var pos = Vector2i(x, z)
|
var pos = Vector2i(x, z)
|
||||||
|
|
||||||
# Skip NPC pump zone (center 3x3)
|
# Skip NPC pump zone (center 3x3)
|
||||||
if x >= 8 and x <= 10 and z >= 8 and z <= 10:
|
if x >= 8 and x <= 10 and z >= 8 and z <= 10:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check base floor — don't spawn on void (or walls if they were still obstacles)
|
# Check base floor — don't spawn on void (or walls if they were still obstacles)
|
||||||
var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z))
|
var base_tile = gridmap.get_cell_item(Vector3i(x, 0, z))
|
||||||
if base_tile == -1:
|
if base_tile == -1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Ensure we don't spawn powerups on the perimeter walls even though they look like floors
|
# Ensure we don't spawn powerups on the perimeter walls even though they look like floors
|
||||||
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
|
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip if something already exists on Layer 1
|
# Skip if something already exists on Layer 1
|
||||||
var current_item = gridmap.get_cell_item(Vector3i(x, 1, z))
|
var current_item = gridmap.get_cell_item(Vector3i(x, 1, z))
|
||||||
if current_item != -1:
|
if current_item != -1:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Spawn tiles with 60% density (40% chance to skip)
|
# Spawn tiles with 60% density (40% chance to skip)
|
||||||
if randf() > 0.6:
|
if randf() > 0.6:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
var tile_type = goal_items[randi() % goal_items.size()]
|
var tile_type = goal_items[randi() % goal_items.size()]
|
||||||
gridmap.set_cell_item(Vector3i(x, 1, z), tile_type)
|
gridmap.set_cell_item(Vector3i(x, 1, z), tile_type)
|
||||||
tiles_spawned += 1
|
tiles_spawned += 1
|
||||||
|
|
||||||
# Sync to clients
|
# Sync to clients
|
||||||
|
var main = get_node("/root/Main")
|
||||||
if main:
|
if main:
|
||||||
main.rpc("sync_grid_item", x, 1, z, tile_type)
|
main.rpc("sync_grid_item", x, 1, z, tile_type)
|
||||||
|
|
||||||
@@ -1003,6 +1004,56 @@ func _spawn_impact_particles(targets: Array) -> void:
|
|||||||
if particles and is_instance_valid(particles):
|
if particles and is_instance_valid(particles):
|
||||||
particles.queue_free()
|
particles.queue_free()
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
func _spawn_cleanser_particles(pos: Vector2i) -> void:
|
||||||
|
"""Spawn bright cleansing particles when sticky is cleared."""
|
||||||
|
if not main_scene or not gridmap:
|
||||||
|
return
|
||||||
|
|
||||||
|
var world_pos = Vector3(
|
||||||
|
pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
|
||||||
|
0.5,
|
||||||
|
pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
var particles = GPUParticles3D.new()
|
||||||
|
particles.emitting = true
|
||||||
|
particles.one_shot = true
|
||||||
|
particles.amount = 12
|
||||||
|
particles.lifetime = 0.6
|
||||||
|
particles.explosiveness = 0.9
|
||||||
|
|
||||||
|
var material = ParticleProcessMaterial.new()
|
||||||
|
material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||||||
|
material.emission_sphere_radius = 0.3
|
||||||
|
material.direction = Vector3(0, 1, 0)
|
||||||
|
material.spread = 180.0
|
||||||
|
material.initial_velocity_min = 3.0
|
||||||
|
material.initial_velocity_max = 5.0
|
||||||
|
material.gravity = Vector3(0, -5.0, 0)
|
||||||
|
material.scale_min = 0.05
|
||||||
|
material.scale_max = 0.15
|
||||||
|
|
||||||
|
var mesh = SphereMesh.new()
|
||||||
|
mesh.radius = 0.2
|
||||||
|
mesh.height = 0.4
|
||||||
|
var spatial_mat = StandardMaterial3D.new()
|
||||||
|
spatial_mat.albedo_color = Color(0.2, 1.0, 1.0) # Cyan/Blue for cleanser
|
||||||
|
spatial_mat.emission_enabled = true
|
||||||
|
spatial_mat.emission = Color(0.2, 1.0, 1.0)
|
||||||
|
spatial_mat.emission_energy_multiplier = 3.0
|
||||||
|
mesh.material = spatial_mat
|
||||||
|
particles.draw_pass_1 = mesh
|
||||||
|
|
||||||
|
particles.process_material = material
|
||||||
|
particles.position = world_pos
|
||||||
|
|
||||||
|
main_scene.add_child(particles)
|
||||||
|
|
||||||
|
await get_tree().create_timer(1.2).timeout
|
||||||
|
if particles and is_instance_valid(particles):
|
||||||
|
particles.queue_free()
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Sticky / Trap System
|
# Sticky / Trap System
|
||||||
|
|
||||||
@@ -1014,15 +1065,12 @@ func is_cleansed_cell(pos: Vector2i) -> bool:
|
|||||||
|
|
||||||
func cell_state(pos: Vector2i) -> CellState:
|
func cell_state(pos: Vector2i) -> CellState:
|
||||||
"""Logical state of a playable cell (v2 ground-growth model)."""
|
"""Logical state of a playable cell (v2 ground-growth model)."""
|
||||||
var b = get_arena_bounds()
|
|
||||||
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
|
|
||||||
return CellState.STICKY
|
|
||||||
if _is_npc_zone(pos) or _is_boundary(pos):
|
if _is_npc_zone(pos) or _is_boundary(pos):
|
||||||
return CellState.BLOCKED
|
return CellState.BLOCKED
|
||||||
if is_sticky_cell(pos):
|
if sticky_cells.has(pos):
|
||||||
return CellState.STICKY
|
return CellState.STICKY
|
||||||
if cleansed_cells.has(pos):
|
if cleansed_cells.has(pos):
|
||||||
return CellState.BLOCKED # Protected from regrowth temporarily
|
return CellState.CLEANSED
|
||||||
if telegraphed_cells.has(pos):
|
if telegraphed_cells.has(pos):
|
||||||
return CellState.TELEGRAPHED
|
return CellState.TELEGRAPHED
|
||||||
if bubble_cells.has(pos):
|
if bubble_cells.has(pos):
|
||||||
@@ -1043,37 +1091,8 @@ func _tick_cleansed_cells(delta: float) -> void:
|
|||||||
for pos in expired:
|
for pos in expired:
|
||||||
cleansed_cells.erase(pos)
|
cleansed_cells.erase(pos)
|
||||||
|
|
||||||
func get_arena_bounds() -> Dictionary:
|
|
||||||
match current_phase:
|
|
||||||
Phase.OPEN_ARENA:
|
|
||||||
return {"min": 0, "max": 19} # 20x20
|
|
||||||
Phase.ROUTE_PRESSURE:
|
|
||||||
return {"min": 1, "max": 18} # 18x18
|
|
||||||
Phase.SURVIVAL_ENDGAME:
|
|
||||||
return {"min": 6, "max": 12} # 7x7
|
|
||||||
return {"min": 0, "max": 19}
|
|
||||||
|
|
||||||
func _shrink_arena() -> void:
|
|
||||||
if not multiplayer.is_server(): return
|
|
||||||
var b = get_arena_bounds()
|
|
||||||
var new_sticky = []
|
|
||||||
for x in range(ARENA_COLUMNS):
|
|
||||||
for z in range(ARENA_ROWS):
|
|
||||||
var pos = Vector2i(x, z)
|
|
||||||
if _is_npc_zone(pos) or _is_boundary(pos):
|
|
||||||
continue
|
|
||||||
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
|
|
||||||
if not sticky_cells.has(pos):
|
|
||||||
new_sticky.append(pos)
|
|
||||||
|
|
||||||
if new_sticky.size() > 0:
|
|
||||||
if _can_rpc() and multiplayer.is_server():
|
|
||||||
rpc("sync_growth_apply", new_sticky)
|
|
||||||
else:
|
|
||||||
sync_growth_apply(new_sticky)
|
|
||||||
|
|
||||||
func _is_boundary(pos: Vector2i) -> bool:
|
func _is_boundary(pos: Vector2i) -> bool:
|
||||||
return pos.x <= 0 or pos.x >= ARENA_COLUMNS - 1 or pos.y <= 0 or pos.y >= ARENA_ROWS - 1
|
return pos.x == 0 or pos.x == ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == ARENA_ROWS - 1
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Coverage tracking (v2 target: 70-75%, down from v1's 80%)
|
# Coverage tracking (v2 target: 70-75%, down from v1's 80%)
|
||||||
@@ -1084,15 +1103,12 @@ const COVERAGE_TARGET_MAX: float = 0.75
|
|||||||
|
|
||||||
func playable_cell_count() -> int:
|
func playable_cell_count() -> int:
|
||||||
"""Number of cells that can ever become sticky (interior, minus NPC zone)."""
|
"""Number of cells that can ever become sticky (interior, minus NPC zone)."""
|
||||||
var b = get_arena_bounds()
|
|
||||||
var count := 0
|
var count := 0
|
||||||
for x in range(ARENA_COLUMNS):
|
for x in range(ARENA_COLUMNS):
|
||||||
for z in range(ARENA_ROWS):
|
for z in range(ARENA_ROWS):
|
||||||
var pos := Vector2i(x, z)
|
var pos := Vector2i(x, z)
|
||||||
if _is_boundary(pos) or _is_npc_zone(pos):
|
if _is_boundary(pos) or _is_npc_zone(pos):
|
||||||
continue
|
continue
|
||||||
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
|
|
||||||
continue
|
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@@ -1116,9 +1132,6 @@ const FORCED_TRAP_WINDOW: float = 30.0 # final seconds where trapping is allowed
|
|||||||
|
|
||||||
func _is_cell_passable(pos: Vector2i, extra_sticky: Dictionary = {}) -> bool:
|
func _is_cell_passable(pos: Vector2i, extra_sticky: Dictionary = {}) -> bool:
|
||||||
"""Can a player stand on / move through this cell, given a hypothetical sticky set?"""
|
"""Can a player stand on / move through this cell, given a hypothetical sticky set?"""
|
||||||
var b = get_arena_bounds()
|
|
||||||
if pos.x <= b.min or pos.x >= b.max or pos.y <= b.min or pos.y >= b.max:
|
|
||||||
return false
|
|
||||||
if _is_boundary(pos) or _is_npc_zone(pos):
|
if _is_boundary(pos) or _is_npc_zone(pos):
|
||||||
return false
|
return false
|
||||||
if sticky_cells.has(pos) or extra_sticky.has(pos):
|
if sticky_cells.has(pos) or extra_sticky.has(pos):
|
||||||
@@ -1320,11 +1333,11 @@ func _calculate_bubble_score(pos: Vector2i, player_cells: Array = []) -> float:
|
|||||||
return score
|
return score
|
||||||
|
|
||||||
func _bubble_score_camping(pos: Vector2i) -> float:
|
func _bubble_score_camping(pos: Vector2i) -> float:
|
||||||
"""Reward targeting campers. +40 >5s, +60 >8s, +80 >10s-with-ghost."""
|
"""Reward targeting campers. +40 >5s, +60 >8s, +80 >10s-with-cleanser."""
|
||||||
var t := _camp_time_for_region(_region_of(pos))
|
var t := _camp_time_for_region(_region_of(pos))
|
||||||
if t > 10.0:
|
if t > 10.0:
|
||||||
# Stronger only if a nearby player is in ghost mode.
|
# Stronger only if a nearby player actually holds a cleanser.
|
||||||
if _any_ghost_player_near(pos):
|
if _any_cleanser_holder_near(pos):
|
||||||
return 80.0
|
return 80.0
|
||||||
return 60.0
|
return 60.0
|
||||||
elif t > 8.0:
|
elif t > 8.0:
|
||||||
@@ -1377,25 +1390,22 @@ func _bubble_score_unfair_trap(pos: Vector2i) -> float:
|
|||||||
func _bubble_blast_cells(center: Vector2i) -> Array:
|
func _bubble_blast_cells(center: Vector2i) -> Array:
|
||||||
"""The 3x3 (radius 1) sticky cells a bubble at `center` would create,
|
"""The 3x3 (radius 1) sticky cells a bubble at `center` would create,
|
||||||
clipped to passable/playable cells."""
|
clipped to passable/playable cells."""
|
||||||
var b = get_arena_bounds()
|
|
||||||
var cells: Array = []
|
var cells: Array = []
|
||||||
for dx in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1):
|
for dx in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1):
|
||||||
for dz in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1):
|
for dz in range(-BUBBLE_EXPLOSION_RADIUS, BUBBLE_EXPLOSION_RADIUS + 1):
|
||||||
var c := center + Vector2i(dx, dz)
|
var c := center + Vector2i(dx, dz)
|
||||||
if _is_boundary(c) or _is_npc_zone(c):
|
if _is_boundary(c) or _is_npc_zone(c):
|
||||||
continue
|
continue
|
||||||
if c.x <= b.min or c.x >= b.max or c.y <= b.min or c.y >= b.max:
|
|
||||||
continue
|
|
||||||
cells.append(c)
|
cells.append(c)
|
||||||
return cells
|
return cells
|
||||||
|
|
||||||
func _bubble_footprint(center: Vector2i) -> Array:
|
func _any_cleanser_holder_near(pos: Vector2i) -> bool:
|
||||||
return _bubble_blast_cells(center)
|
"""True if a player holding a Cleanser charge is within the camping region."""
|
||||||
|
|
||||||
func _any_ghost_player_near(pos: Vector2i) -> bool:
|
|
||||||
"""True if a player in ghost mode is within the camping region."""
|
|
||||||
for player in get_tree().get_nodes_in_group("Players"):
|
for player in get_tree().get_nodes_in_group("Players"):
|
||||||
if not player.get("is_invisible"):
|
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||||
|
if pid == -1:
|
||||||
|
continue
|
||||||
|
if player_cleansers.get(pid, 0) <= 0:
|
||||||
continue
|
continue
|
||||||
if "current_position" in player and player.current_position != null:
|
if "current_position" in player and player.current_position != null:
|
||||||
if _region_of(player.current_position) == _region_of(pos):
|
if _region_of(player.current_position) == _region_of(pos):
|
||||||
@@ -1491,7 +1501,7 @@ func _explode_bubble(center: Vector2i, cells: Array) -> void:
|
|||||||
if "current_position" in player and player.current_position != null:
|
if "current_position" in player and player.current_position != null:
|
||||||
if blast.has(player.current_position):
|
if blast.has(player.current_position):
|
||||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||||
if pid != -1 and player.get("is_invisible"):
|
if pid != -1 and is_cleanser_active(pid):
|
||||||
continue
|
continue
|
||||||
apply_sticky_slow(player)
|
apply_sticky_slow(player)
|
||||||
|
|
||||||
@@ -1598,8 +1608,8 @@ func _check_all_players_trapped() -> void:
|
|||||||
var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1)
|
var pos = player.current_position if player.get("current_position") else Vector2i(-1, -1)
|
||||||
if is_sticky_cell(pos):
|
if is_sticky_cell(pos):
|
||||||
var pid = player.get("peer_id") if "peer_id" in player else -1
|
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||||
if pid != -1 and player.get("is_invisible"):
|
if pid != -1 and is_cleanser_active(pid):
|
||||||
continue # ghost players are immune to the slow
|
continue # cleansing players are immune to the slow
|
||||||
apply_sticky_slow(player)
|
apply_sticky_slow(player)
|
||||||
|
|
||||||
func apply_sticky_slow(player: Node) -> void:
|
func apply_sticky_slow(player: Node) -> void:
|
||||||
@@ -1631,22 +1641,25 @@ func _trap_player(player: Node) -> void:
|
|||||||
NotificationManager.send_message(player, "Stuck in Candy!", NotificationManager.MessageType.WARNING)
|
NotificationManager.send_message(player, "Stuck in Candy!", NotificationManager.MessageType.WARNING)
|
||||||
|
|
||||||
func clear_sticky_cell(pos: Vector2i) -> void:
|
func clear_sticky_cell(pos: Vector2i) -> void:
|
||||||
"""Remove a sticky cell (used when ghost player walks through)."""
|
"""Used by Cleanser power-up to remove a sticky cell."""
|
||||||
if _can_rpc():
|
if _can_rpc():
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
rpc("sync_clear_sticky_cell", pos)
|
rpc("sync_clear_sticky_cell", pos)
|
||||||
else:
|
else:
|
||||||
sync_clear_sticky_cell(pos) # Predictive local clear
|
sync_clear_sticky_cell(pos) # Predictive local clear
|
||||||
|
rpc("rpc_use_cleanser", pos)
|
||||||
else:
|
else:
|
||||||
sync_clear_sticky_cell(pos)
|
sync_clear_sticky_cell(pos)
|
||||||
|
|
||||||
@rpc("authority", "call_local", "reliable")
|
@rpc("authority", "call_local", "reliable")
|
||||||
func sync_clear_sticky_cell(pos: Vector2i) -> void:
|
func sync_clear_sticky_cell(pos: Vector2i) -> void:
|
||||||
sticky_cells.erase(pos)
|
sticky_cells.erase(pos)
|
||||||
mark_cleansed(pos) # temporary regrowth protection
|
mark_cleansed(pos) # temporary regrowth protection (v2)
|
||||||
if gridmap:
|
if gridmap:
|
||||||
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
|
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1)
|
||||||
|
|
||||||
|
# Play VFX and SFX
|
||||||
|
_spawn_cleanser_particles(pos)
|
||||||
if SfxManager:
|
if SfxManager:
|
||||||
SfxManager.play("pick_up_power_tile")
|
SfxManager.play("pick_up_power_tile")
|
||||||
|
|
||||||
@@ -1654,6 +1667,142 @@ func sync_clear_sticky_cell(pos: Vector2i) -> void:
|
|||||||
if main_scene and main_scene.has_method("sync_grid_item"):
|
if main_scene and main_scene.has_method("sync_grid_item"):
|
||||||
main_scene.sync_grid_item(pos.x, 2, pos.y, -1)
|
main_scene.sync_grid_item(pos.x, 2, pos.y, -1)
|
||||||
|
|
||||||
|
func _try_use_cleanser() -> void:
|
||||||
|
"""Local player attempts to activate Cleanser for 5-cell sticky immunity."""
|
||||||
|
var local_pid = multiplayer.get_unique_id()
|
||||||
|
var count = player_cleansers.get(local_pid, 0)
|
||||||
|
if count <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Block activation during stun
|
||||||
|
var all_players = get_tree().get_nodes_in_group("Players")
|
||||||
|
var local_player = null
|
||||||
|
for p in all_players:
|
||||||
|
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||||||
|
if pid == local_pid:
|
||||||
|
local_player = p
|
||||||
|
break
|
||||||
|
if not local_player:
|
||||||
|
return
|
||||||
|
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
|
||||||
|
return
|
||||||
|
# 0.3s activation delay
|
||||||
|
await get_tree().create_timer(CLEANSER_ACTIVATION_DELAY).timeout
|
||||||
|
|
||||||
|
# Re-validate after delay
|
||||||
|
if not is_instance_valid(local_player):
|
||||||
|
return
|
||||||
|
if local_player.get("is_frozen") or local_player.get("is_stop_frozen"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Consume cleanser from inventory (only if client, host relies on rpc)
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
cleanser_active[local_pid] = true
|
||||||
|
cleanser_cells_left[local_pid] = CLEANSER_MAX_CELLS
|
||||||
|
player_cleansers[local_pid] = max(0, player_cleansers[local_pid] - 1)
|
||||||
|
update_cleanser_ui(player_cleansers[local_pid])
|
||||||
|
|
||||||
|
NotificationManager.send_message(local_player, "Cleanser Used! (5 charges)", NotificationManager.MessageType.POWERUP)
|
||||||
|
|
||||||
|
# Sync to server/clients
|
||||||
|
if not multiplayer.is_server() and _can_rpc():
|
||||||
|
rpc("rpc_activate_cleanser", local_pid)
|
||||||
|
elif multiplayer.is_server():
|
||||||
|
# Call RPC logic directly for host (it will set active/cells_left/consume)
|
||||||
|
rpc_activate_cleanser(local_pid)
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_local", "reliable")
|
||||||
|
func deactivate_cleanser(player_id: int) -> void:
|
||||||
|
"""Deactivate cleanser immunity for a player."""
|
||||||
|
cleanser_active.erase(player_id)
|
||||||
|
cleanser_cells_left.erase(player_id)
|
||||||
|
|
||||||
|
func is_cleanser_active(player_id: int) -> bool:
|
||||||
|
"""Check if a player has active cleanser immunity."""
|
||||||
|
return cleanser_active.has(player_id)
|
||||||
|
|
||||||
|
func use_cleanser_cell(player_id: int) -> bool:
|
||||||
|
"""Use one cleanser cell. Returns true if still active, false if exhausted."""
|
||||||
|
if not cleanser_active.has(player_id):
|
||||||
|
return false
|
||||||
|
cleanser_cells_left[player_id] -= 1
|
||||||
|
if cleanser_cells_left[player_id] <= 0:
|
||||||
|
if _can_rpc():
|
||||||
|
rpc("deactivate_cleanser", player_id)
|
||||||
|
else:
|
||||||
|
deactivate_cleanser(player_id)
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
func notify_movement_stopped(player_id: int, pos: Vector2i) -> void:
|
||||||
|
"""Called from PlayerMovementManager when a move chain settles.
|
||||||
|
Previously deactivated cleanser here, but now immunity persists
|
||||||
|
until charges run out to allow repeated use across safe gaps."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_local", "reliable")
|
||||||
|
func rpc_activate_cleanser(pid: int) -> void:
|
||||||
|
"""RPC for clients to activate cleanser on server."""
|
||||||
|
if multiplayer.is_server():
|
||||||
|
# Verify they actually have a cleanser charge (prevents spam/cheats)
|
||||||
|
if player_cleansers.get(pid, 0) <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Always apply the state and AoE, since this is the server authority
|
||||||
|
cleanser_active[pid] = true
|
||||||
|
cleanser_cells_left[pid] = CLEANSER_MAX_CELLS
|
||||||
|
player_cleansers[pid] = max(0, player_cleansers[pid] - 1)
|
||||||
|
if _can_rpc():
|
||||||
|
rpc("sync_cleanser_count", pid, player_cleansers[pid])
|
||||||
|
|
||||||
|
# NEW: Clear 3x3 area around player
|
||||||
|
var all_players = get_tree().get_nodes_in_group("Players")
|
||||||
|
var target_player = null
|
||||||
|
for p in all_players:
|
||||||
|
var target_pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
||||||
|
if target_pid == pid:
|
||||||
|
target_player = p
|
||||||
|
break
|
||||||
|
|
||||||
|
if gridmap and is_instance_valid(target_player):
|
||||||
|
var map_pos = gridmap.local_to_map(target_player.global_position)
|
||||||
|
var center_pos = Vector2i(map_pos.x, map_pos.z)
|
||||||
|
|
||||||
|
# 3x3 neighborhood
|
||||||
|
for dx in range(-1, 2):
|
||||||
|
for dz in range(-1, 2):
|
||||||
|
var check_pos = center_pos + Vector2i(dx, dz)
|
||||||
|
if is_sticky_cell(check_pos):
|
||||||
|
clear_sticky_cell(check_pos)
|
||||||
|
|
||||||
|
# Remove slow effect for any player in the cleansed area
|
||||||
|
for p in all_players:
|
||||||
|
if is_instance_valid(p) and p.has_method("remove_slow_effect"):
|
||||||
|
if gridmap:
|
||||||
|
var p_map_pos = gridmap.local_to_map(p.global_position)
|
||||||
|
var p_cell_pos = Vector2i(p_map_pos.x, p_map_pos.z)
|
||||||
|
if abs(p_cell_pos.x - center_pos.x) <= 1 and abs(p_cell_pos.y - center_pos.y) <= 1:
|
||||||
|
if _can_rpc():
|
||||||
|
p.rpc("remove_slow_effect")
|
||||||
|
else:
|
||||||
|
p.remove_slow_effect()
|
||||||
|
|
||||||
|
print("[Cleanser] Server cleared 3x3 area around %s for player %d" % [center_pos, pid])
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_local", "reliable")
|
||||||
|
func rpc_use_cleanser(pos: Vector2i) -> void:
|
||||||
|
"""RPC for clients to clear a sticky cell via Cleanser."""
|
||||||
|
if multiplayer.is_server():
|
||||||
|
clear_sticky_cell(pos)
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_local", "reliable")
|
||||||
|
func rpc_consume_cleanser(pid: int) -> void:
|
||||||
|
"""RPC for clients to report Cleanser consumption to server."""
|
||||||
|
if multiplayer.is_server():
|
||||||
|
player_cleansers[pid] = 0
|
||||||
|
if _can_rpc():
|
||||||
|
rpc("sync_cleanser_count", pid, 0)
|
||||||
|
|
||||||
@rpc("any_peer", "reliable")
|
@rpc("any_peer", "reliable")
|
||||||
func rpc_trigger_slowmo() -> void:
|
func rpc_trigger_slowmo() -> void:
|
||||||
"""RPC for clients to request slow-mo from server."""
|
"""RPC for clients to request slow-mo from server."""
|
||||||
@@ -1740,7 +1889,25 @@ func _setup_hud() -> void:
|
|||||||
hud_layer.visible = false
|
hud_layer.visible = false
|
||||||
add_child(hud_layer)
|
add_child(hud_layer)
|
||||||
phase_label = hud_layer.get_node("BottomContainer/VBoxContainer/PhaseLabel")
|
phase_label = hud_layer.get_node("BottomContainer/VBoxContainer/PhaseLabel")
|
||||||
|
cleanser_icon = hud_layer.get_node("BottomContainer/VBoxContainer/CleanserHBox/CleanserIcon")
|
||||||
|
cleanser_label = hud_layer.get_node("BottomContainer/VBoxContainer/CleanserHBox/CleanserLabel")
|
||||||
slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel")
|
slowmo_label = hud_layer.get_node_or_null("TopContainer/SlowMoLabel")
|
||||||
|
_generate_cleanser_icon()
|
||||||
|
|
||||||
|
func _generate_cleanser_icon() -> void:
|
||||||
|
var icon_img = Image.create(16, 16, false, Image.FORMAT_RGBA8)
|
||||||
|
icon_img.fill(Color(0.4, 0.9, 1.0))
|
||||||
|
icon_img.blend_rect(icon_img, Rect2i(2, 2, 12, 12), Vector2i(1, 1))
|
||||||
|
for x in range(16):
|
||||||
|
icon_img.set_pixel(x, 0, Color(0.2, 0.6, 0.7))
|
||||||
|
icon_img.set_pixel(x, 15, Color(0.2, 0.6, 0.7))
|
||||||
|
for y in range(16):
|
||||||
|
icon_img.set_pixel(0, y, Color(0.2, 0.6, 0.7))
|
||||||
|
icon_img.set_pixel(15, y, Color(0.2, 0.6, 0.7))
|
||||||
|
for i in range(4, 12):
|
||||||
|
icon_img.set_pixel(i, 7, Color(1.0, 1.0, 1.0, 0.8))
|
||||||
|
icon_img.set_pixel(7, i, Color(1.0, 1.0, 1.0, 0.8))
|
||||||
|
cleanser_icon.texture = ImageTexture.create_from_image(icon_img)
|
||||||
|
|
||||||
func _update_hud_phase(phase_name: String) -> void:
|
func _update_hud_phase(phase_name: String) -> void:
|
||||||
if phase_label:
|
if phase_label:
|
||||||
@@ -1759,6 +1926,20 @@ func _update_hud_phase(phase_name: String) -> void:
|
|||||||
# Animate phase label with bounce effect
|
# Animate phase label with bounce effect
|
||||||
_animate_phase_label()
|
_animate_phase_label()
|
||||||
|
|
||||||
|
func update_cleanser_ui(count: int) -> void:
|
||||||
|
cleanser_count = count
|
||||||
|
if cleanser_label:
|
||||||
|
cleanser_label.text = "[E] Cleanser (%d)" % count
|
||||||
|
# Show/hide icon based on availability
|
||||||
|
if cleanser_icon:
|
||||||
|
cleanser_icon.visible = count > 0
|
||||||
|
if count > 0:
|
||||||
|
# Pulse animation when cleanser is available
|
||||||
|
var tween = create_tween()
|
||||||
|
tween.set_loops(2)
|
||||||
|
tween.tween_property(cleanser_icon, "modulate", Color(1.5, 1.5, 1.5, 1), 0.3)
|
||||||
|
tween.tween_property(cleanser_icon, "modulate", Color.WHITE, 0.3)
|
||||||
|
|
||||||
func _animate_phase_label() -> void:
|
func _animate_phase_label() -> void:
|
||||||
"""Animate phase label with bounce effect."""
|
"""Animate phase label with bounce effect."""
|
||||||
if not phase_label:
|
if not phase_label:
|
||||||
@@ -1783,7 +1964,7 @@ func _animate_phase_label() -> void:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
func _on_goal_count_updated(peer_id: int, count: int) -> void:
|
func _on_goal_count_updated(peer_id: int, count: int) -> void:
|
||||||
"""Called when a player completes a goal cycle. Grant ghost powerup every 2 missions."""
|
"""Called when a player completes a goal cycle. Grant cleanser every 2 missions."""
|
||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1792,29 +1973,34 @@ func _on_goal_count_updated(peer_id: int, count: int) -> void:
|
|||||||
player_mission_completions[peer_id] = 0
|
player_mission_completions[peer_id] = 0
|
||||||
player_mission_completions[peer_id] += 1
|
player_mission_completions[peer_id] += 1
|
||||||
|
|
||||||
# Grant ghost powerup every 2 missions
|
# Grant cleanser every 2 missions
|
||||||
var completions = player_mission_completions[peer_id]
|
var completions = player_mission_completions[peer_id]
|
||||||
if completions % 2 == 0:
|
if completions % 2 == 0:
|
||||||
_grant_ghost_powerup(peer_id)
|
if not player_cleansers.has(peer_id):
|
||||||
|
player_cleansers[peer_id] = 0
|
||||||
func _grant_ghost_powerup(peer_id: int) -> void:
|
|
||||||
"""Grant the ghost (invisible mode) powerup to a player."""
|
# Allow stacking cleanser charges instead of capping at 1
|
||||||
var all_players = get_tree().get_nodes_in_group("Players")
|
player_cleansers[peer_id] += 1
|
||||||
for p in all_players:
|
emit_signal("cleanser_granted", peer_id)
|
||||||
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
|
print("[Gauntlet] Player %d granted Cleanser (Total: %d) (mission %d)" % [peer_id, player_cleansers[peer_id], completions])
|
||||||
if pid == peer_id:
|
|
||||||
var stm = p.get_node_or_null("SpecialTilesManager")
|
# Sync cleanser count to HUD
|
||||||
if stm and stm.has_method("add_powerup_from_item"):
|
rpc("sync_cleanser_count", peer_id, player_cleansers.get(peer_id, 0))
|
||||||
stm.add_powerup_from_item(14) # 14 = Ghost / INVISIBLE_MODE
|
|
||||||
emit_signal("ghost_granted", peer_id)
|
|
||||||
print("[Gauntlet] Player %d granted Ghost powerup (mission %d)" % [peer_id, player_mission_completions[peer_id]])
|
|
||||||
NotificationManager.send_message(p, "Ghost Power Earned!", NotificationManager.MessageType.POWERUP)
|
|
||||||
break
|
|
||||||
|
|
||||||
func _on_score_updated(peer_id: int, new_score: int) -> void:
|
func _on_score_updated(peer_id: int, new_score: int) -> void:
|
||||||
"""Called when a player's score is updated."""
|
"""Called when a player's score is updated."""
|
||||||
pass # Score sync handled by GoalsCycleManager
|
pass # Score sync handled by GoalsCycleManager
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_cleanser_count(peer_id: int, count: int) -> void:
|
||||||
|
"""Sync cleanser count to HUD for specific player."""
|
||||||
|
# Update local player's cleanser UI
|
||||||
|
var local_pid = multiplayer.get_unique_id()
|
||||||
|
if peer_id == local_pid:
|
||||||
|
update_cleanser_ui(count)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Utility
|
# Utility
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ signal gauntlet_round_duration_changed(duration: int)
|
|||||||
signal gauntlet_growth_interval_changed(interval: float)
|
signal gauntlet_growth_interval_changed(interval: float)
|
||||||
signal gauntlet_cells_per_tick_changed(cells: Dictionary)
|
signal gauntlet_cells_per_tick_changed(cells: Dictionary)
|
||||||
|
|
||||||
|
# Mekton Bulls settings signals
|
||||||
|
signal mekton_bulls_round_duration_changed(duration: int)
|
||||||
|
signal mekton_bulls_phase_interval_changed(interval: int)
|
||||||
|
signal mekton_bulls_points_changed(min_pts: int, max_pts: int)
|
||||||
|
|
||||||
# Room data structure
|
# Room data structure
|
||||||
var current_room: Dictionary = {}
|
var current_room: Dictionary = {}
|
||||||
var players_in_room: Array = [] # [{id, name, is_ready}]
|
var players_in_room: Array = [] # [{id, name, is_ready}]
|
||||||
@@ -88,13 +93,19 @@ var gauntlet_cells_per_tick: Dictionary = {
|
|||||||
"phase3": [8, 10],
|
"phase3": [8, 10],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mekton Bulls settings
|
||||||
|
var mekton_bulls_round_duration: int = 120
|
||||||
|
var mekton_bulls_phase_interval: int = 30
|
||||||
|
var mekton_bulls_min_points: int = 100
|
||||||
|
var mekton_bulls_max_points: int = 1000
|
||||||
|
|
||||||
# Rematch tracking
|
# Rematch tracking
|
||||||
var rematch_votes: Array = [] # [player_id, ...]
|
var rematch_votes: Array = [] # [player_id, ...]
|
||||||
|
|
||||||
# Character and area selection
|
# Character and area selection
|
||||||
var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"]
|
var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"]
|
||||||
var available_areas: Array[String] = []
|
var available_areas: Array[String] = []
|
||||||
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Pump Survival"]
|
var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival", "Mekton Bulls"]
|
||||||
var selected_area: String = "Freemode Arena" # Host-controlled
|
var selected_area: String = "Freemode Arena" # Host-controlled
|
||||||
var game_mode: String = "Freemode" # Host-controlled
|
var game_mode: String = "Freemode" # Host-controlled
|
||||||
var local_character_index: int = 0 # Local player's character index
|
var local_character_index: int = 0 # Local player's character index
|
||||||
@@ -149,8 +160,12 @@ func _update_available_areas(mode: String) -> void:
|
|||||||
available_areas = ["Freemode Arena", "Classic", "Colloseum"]
|
available_areas = ["Freemode Arena", "Classic", "Colloseum"]
|
||||||
"Stop n Go":
|
"Stop n Go":
|
||||||
available_areas = ["Stop N Go Arena"]
|
available_areas = ["Stop N Go Arena"]
|
||||||
|
"Tekton Doors":
|
||||||
|
available_areas = ["Tekton Doors Area"]
|
||||||
"Candy Pump Survival":
|
"Candy Pump Survival":
|
||||||
available_areas = ["Gauntlet Arena"]
|
available_areas = ["Gauntlet Arena"]
|
||||||
|
"Mekton Bulls":
|
||||||
|
available_areas = ["Mekton Bulls Arena"]
|
||||||
_:
|
_:
|
||||||
available_areas = ["Classic"]
|
available_areas = ["Classic"]
|
||||||
|
|
||||||
@@ -584,6 +599,39 @@ func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void:
|
|||||||
gauntlet_cells_per_tick = cells
|
gauntlet_cells_per_tick = cells
|
||||||
emit_signal("gauntlet_cells_per_tick_changed", cells)
|
emit_signal("gauntlet_cells_per_tick_changed", cells)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Mekton Bulls Settings
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
func set_mekton_bulls_round_duration(duration: int) -> void:
|
||||||
|
mekton_bulls_round_duration = duration
|
||||||
|
if is_host: rpc("sync_mekton_bulls_round_duration", duration)
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_mekton_bulls_round_duration(duration: int) -> void:
|
||||||
|
mekton_bulls_round_duration = duration
|
||||||
|
emit_signal("mekton_bulls_round_duration_changed", duration)
|
||||||
|
|
||||||
|
func set_mekton_bulls_phase_interval(interval: int) -> void:
|
||||||
|
mekton_bulls_phase_interval = interval
|
||||||
|
if is_host: rpc("sync_mekton_bulls_phase_interval", interval)
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_mekton_bulls_phase_interval(interval: int) -> void:
|
||||||
|
mekton_bulls_phase_interval = interval
|
||||||
|
emit_signal("mekton_bulls_phase_interval_changed", interval)
|
||||||
|
|
||||||
|
func set_mekton_bulls_points(min_pts: int, max_pts: int) -> void:
|
||||||
|
mekton_bulls_min_points = min_pts
|
||||||
|
mekton_bulls_max_points = max_pts
|
||||||
|
if is_host: rpc("sync_mekton_bulls_points", min_pts, max_pts)
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_mekton_bulls_points(min_pts: int, max_pts: int) -> void:
|
||||||
|
mekton_bulls_min_points = min_pts
|
||||||
|
mekton_bulls_max_points = max_pts
|
||||||
|
emit_signal("mekton_bulls_points_changed", min_pts, max_pts)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Character Selection
|
# Character Selection
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -792,6 +840,10 @@ func start_game(force: bool = false) -> void:
|
|||||||
rpc("sync_gauntlet_round_duration", gauntlet_round_duration)
|
rpc("sync_gauntlet_round_duration", gauntlet_round_duration)
|
||||||
rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
||||||
rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
||||||
|
# Sync mekton bulls
|
||||||
|
rpc("sync_mekton_bulls_round_duration", mekton_bulls_round_duration)
|
||||||
|
rpc("sync_mekton_bulls_phase_interval", mekton_bulls_phase_interval)
|
||||||
|
rpc("sync_mekton_bulls_points", mekton_bulls_min_points, mekton_bulls_max_points)
|
||||||
# Sync game mode
|
# Sync game mode
|
||||||
rpc("sync_game_mode", game_mode)
|
rpc("sync_game_mode", game_mode)
|
||||||
|
|
||||||
@@ -870,6 +922,9 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
|
|||||||
rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration)
|
rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration)
|
||||||
rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval)
|
||||||
rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick)
|
||||||
|
rpc_id(requester_id, "sync_mekton_bulls_round_duration", mekton_bulls_round_duration)
|
||||||
|
rpc_id(requester_id, "sync_mekton_bulls_phase_interval", mekton_bulls_phase_interval)
|
||||||
|
rpc_id(requester_id, "sync_mekton_bulls_points", mekton_bulls_min_points, mekton_bulls_max_points)
|
||||||
rpc_id(requester_id, "sync_game_mode", game_mode)
|
rpc_id(requester_id, "sync_game_mode", game_mode)
|
||||||
rpc_id(requester_id, "sync_area", selected_area)
|
rpc_id(requester_id, "sync_area", selected_area)
|
||||||
|
|
||||||
@@ -1021,3 +1076,7 @@ func reset() -> void:
|
|||||||
doors_swap_time = 15
|
doors_swap_time = 15
|
||||||
doors_refresh_time = 25
|
doors_refresh_time = 25
|
||||||
doors_required_goals = 8
|
doors_required_goals = 8
|
||||||
|
mekton_bulls_round_duration = 120
|
||||||
|
mekton_bulls_phase_interval = 30
|
||||||
|
mekton_bulls_min_points = 100
|
||||||
|
mekton_bulls_max_points = 1000
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
extends Node
|
||||||
|
class_name MektonBullsManager
|
||||||
|
|
||||||
|
class Blueprint3x3 extends RefCounted:
|
||||||
|
var anchor: Vector2i
|
||||||
|
var color: int
|
||||||
|
var cells: Array[Vector2i] = []
|
||||||
|
var progress: int = 0
|
||||||
|
|
||||||
|
signal phase_changed(phase_index: int)
|
||||||
|
signal player_eliminated(player_id: int)
|
||||||
|
|
||||||
|
# Nodes
|
||||||
|
var main_scene: Node
|
||||||
|
var gridmap: Node
|
||||||
|
|
||||||
|
# Phase State
|
||||||
|
var current_phase: int = 1
|
||||||
|
var arena_size: Vector2i = Vector2i(20, 20)
|
||||||
|
|
||||||
|
var round_duration: float = 120.0
|
||||||
|
var phase_interval: float = 30.0
|
||||||
|
var round_timer: float = 120.0
|
||||||
|
var phase_timer: float = 30.0
|
||||||
|
|
||||||
|
signal time_remaining_changed(remaining: float)
|
||||||
|
|
||||||
|
var bull_node: Node3D = null
|
||||||
|
const BULL_SCENE = preload("res://scenes/npcs/mekton_bull.tscn")
|
||||||
|
|
||||||
|
enum CellState {
|
||||||
|
SAFE,
|
||||||
|
WATER,
|
||||||
|
BLOCKED
|
||||||
|
}
|
||||||
|
var arena_cells: Dictionary = {}
|
||||||
|
|
||||||
|
var is_active: bool = false
|
||||||
|
var flood_cooldown: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
var player_blueprints: Dictionary = {} # { player_id: Blueprint3x3 }
|
||||||
|
var player_powers: Dictionary = {} # { player_id: { "FREEZE": 0, "KNOCK": 0 } }
|
||||||
|
|
||||||
|
var player_cooldowns: Dictionary = {} # { player_id: float }
|
||||||
|
enum PowerType { FREEZE, KNOCK }
|
||||||
|
|
||||||
|
# Placement Tracking
|
||||||
|
var player_placement: Dictionary = {} # { pid: placement_rank } 1=first out
|
||||||
|
var elimination_order: Array = [] # List of pids
|
||||||
|
|
||||||
|
var candy_tick_timer: float = 0.0
|
||||||
|
const GOAL_COLORS = [7, 8, 9, 10]
|
||||||
|
|
||||||
|
|
||||||
|
const TILE_WALKABLE: int = 0
|
||||||
|
const TILE_WATER: int = 24 # Water tile
|
||||||
|
const TILE_OBSTACLE: int = 4 # Wall/obstacle
|
||||||
|
|
||||||
|
func initialize(main: Node, grid: Node) -> void:
|
||||||
|
main_scene = main
|
||||||
|
gridmap = grid
|
||||||
|
|
||||||
|
|
||||||
|
func start_game_mode() -> void:
|
||||||
|
print("[MektonBulls] Starting Mekton Bulls game mode...")
|
||||||
|
|
||||||
|
round_duration = float(LobbyManager.mekton_bulls_round_duration)
|
||||||
|
phase_interval = float(LobbyManager.mekton_bulls_phase_interval)
|
||||||
|
round_timer = round_duration
|
||||||
|
phase_timer = phase_interval
|
||||||
|
|
||||||
|
is_active = true
|
||||||
|
_setup_arena()
|
||||||
|
|
||||||
|
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||||
|
_spawn_bull()
|
||||||
|
_assign_initial_blueprints()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
player_eliminated.connect(_on_player_eliminated)
|
||||||
|
|
||||||
|
func _setup_arena() -> void:
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
|
||||||
|
if not gridmap:
|
||||||
|
push_error("[MektonBulls] No EnhancedGridMap found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("[MektonBulls] Setting up Phase 1 Arena...")
|
||||||
|
|
||||||
|
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||||
|
rpc("sync_arena_setup")
|
||||||
|
_apply_arena_setup()
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func sync_arena_setup() -> void:
|
||||||
|
_apply_arena_setup()
|
||||||
|
|
||||||
|
func _apply_arena_setup() -> void:
|
||||||
|
if not gridmap:
|
||||||
|
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
|
||||||
|
if not gridmap: return
|
||||||
|
|
||||||
|
current_phase = 1
|
||||||
|
arena_size = arena_size_for_phase(current_phase)
|
||||||
|
|
||||||
|
gridmap.set("columns", 20)
|
||||||
|
gridmap.set("rows", 20)
|
||||||
|
gridmap.clear()
|
||||||
|
|
||||||
|
# Initial build 20x20
|
||||||
|
for x in range(20):
|
||||||
|
for z in range(20):
|
||||||
|
var pos = Vector2i(x, z)
|
||||||
|
|
||||||
|
if _is_boundary(pos):
|
||||||
|
# Perimeter
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||||
|
arena_cells[pos] = CellState.SAFE
|
||||||
|
else:
|
||||||
|
# Walkable floor
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||||
|
arena_cells[pos] = CellState.SAFE
|
||||||
|
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 2, z), -1)
|
||||||
|
|
||||||
|
gridmap.diagonal_movement = true
|
||||||
|
gridmap.update_grid_data()
|
||||||
|
gridmap.initialize_astar()
|
||||||
|
|
||||||
|
_reposition_npc()
|
||||||
|
_validate_blueprint_after_shrink()
|
||||||
|
|
||||||
|
|
||||||
|
func arena_size_for_phase(phase: int) -> Vector2i:
|
||||||
|
match phase:
|
||||||
|
1: return Vector2i(20, 20)
|
||||||
|
2: return Vector2i(19, 19)
|
||||||
|
3: return Vector2i(18, 18)
|
||||||
|
4: return Vector2i(17, 17)
|
||||||
|
_: return Vector2i(17, 17) # Final phase
|
||||||
|
|
||||||
|
func _is_boundary(pos: Vector2i) -> bool:
|
||||||
|
var bounds = arena_size_for_phase(current_phase)
|
||||||
|
# Grid shrinks symmetrically towards (0,0) by bounds.
|
||||||
|
return pos.x == 0 or pos.x == bounds.x - 1 or pos.y == 0 or pos.y == bounds.y - 1
|
||||||
|
|
||||||
|
func _shrink_arena() -> void:
|
||||||
|
if current_phase >= 4:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_phase += 1
|
||||||
|
var new_bounds = arena_size_for_phase(current_phase)
|
||||||
|
var old_bounds = arena_size_for_phase(current_phase - 1)
|
||||||
|
|
||||||
|
print("[MektonBulls] Shrinking arena to Phase %d (%dx%d)" % [current_phase, new_bounds.x, new_bounds.y])
|
||||||
|
|
||||||
|
# Apply locally first
|
||||||
|
_apply_ring_shrink(old_bounds, new_bounds)
|
||||||
|
|
||||||
|
if multiplayer.has_multiplayer_peer() and multiplayer.is_server():
|
||||||
|
rpc("sync_shrink_arena", current_phase)
|
||||||
|
|
||||||
|
emit_signal("phase_changed", current_phase)
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func sync_shrink_arena(new_phase: int) -> void:
|
||||||
|
var old_bounds = arena_size_for_phase(current_phase)
|
||||||
|
current_phase = new_phase
|
||||||
|
var new_bounds = arena_size_for_phase(current_phase)
|
||||||
|
_apply_ring_shrink(old_bounds, new_bounds)
|
||||||
|
emit_signal("phase_changed", current_phase)
|
||||||
|
|
||||||
|
func _apply_ring_shrink(old_bounds: Vector2i, new_bounds: Vector2i) -> void:
|
||||||
|
for x in range(20):
|
||||||
|
for z in range(20):
|
||||||
|
var pos = Vector2i(x, z)
|
||||||
|
|
||||||
|
if x >= new_bounds.x or z >= new_bounds.y:
|
||||||
|
# It is now outside the new bounds -> WATER
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WATER)
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||||
|
arena_cells[pos] = CellState.WATER
|
||||||
|
elif _is_boundary(pos):
|
||||||
|
# New boundary -> No wall, just the edge of SAFE
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||||
|
arena_cells[pos] = CellState.SAFE
|
||||||
|
|
||||||
|
gridmap.update_grid_data()
|
||||||
|
gridmap.initialize_astar()
|
||||||
|
|
||||||
|
_reposition_npc()
|
||||||
|
_validate_blueprint_after_shrink()
|
||||||
|
|
||||||
|
|
||||||
|
func _reposition_npc() -> void:
|
||||||
|
# Reposition Mekton Bull to the center of the current bounds
|
||||||
|
if not bull_node:
|
||||||
|
return
|
||||||
|
|
||||||
|
var bounds = arena_size_for_phase(current_phase)
|
||||||
|
# Center in world units
|
||||||
|
var cx = (bounds.x / 2.0) * gridmap.cell_size.x
|
||||||
|
var cz = (bounds.y / 2.0) * gridmap.cell_size.z
|
||||||
|
|
||||||
|
bull_node.position = Vector3(cx, 0, cz)
|
||||||
|
|
||||||
|
func get_spawn_points(player_count: int) -> Array[Vector2i]:
|
||||||
|
var spawns: Array[Vector2i] = []
|
||||||
|
var bounds = arena_size_for_phase(current_phase)
|
||||||
|
# 4 players: inner corners
|
||||||
|
spawns.append(Vector2i(1, 1))
|
||||||
|
if bounds.x > 2: spawns.append(Vector2i(bounds.x - 2, 1))
|
||||||
|
if bounds.y > 2: spawns.append(Vector2i(1, bounds.y - 2))
|
||||||
|
if bounds.x > 2 and bounds.y > 2: spawns.append(Vector2i(bounds.x - 2, bounds.y - 2))
|
||||||
|
|
||||||
|
if player_count > 4:
|
||||||
|
if bounds.x > 4: spawns.append(Vector2i(bounds.x / 2, 1))
|
||||||
|
if bounds.x > 4 and bounds.y > 2: spawns.append(Vector2i(bounds.x / 2, bounds.y - 2))
|
||||||
|
if player_count > 6:
|
||||||
|
if bounds.y > 4: spawns.append(Vector2i(1, bounds.y / 2))
|
||||||
|
if bounds.x > 2 and bounds.y > 4: spawns.append(Vector2i(bounds.x - 2, bounds.y / 2))
|
||||||
|
|
||||||
|
return spawns.slice(0, player_count)
|
||||||
|
|
||||||
|
func _spawn_bull() -> void:
|
||||||
|
if bull_node == null:
|
||||||
|
bull_node = BULL_SCENE.instantiate()
|
||||||
|
bull_node.name = "MektonBull"
|
||||||
|
# Use multiplayer spawner if appropriate, else just add child
|
||||||
|
main_scene.add_child(bull_node)
|
||||||
|
|
||||||
|
var bounds = arena_size_for_phase(current_phase)
|
||||||
|
var cx = (bounds.x / 2.0) * gridmap.cell_size.x
|
||||||
|
var cz = (bounds.y / 2.0) * gridmap.cell_size.z
|
||||||
|
var start_pos = Vector3(cx, 0, cz)
|
||||||
|
|
||||||
|
if bull_node.has_method("initialize"):
|
||||||
|
bull_node.initialize(self, gridmap, start_pos)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer == null: return
|
||||||
|
if not is_active: return
|
||||||
|
|
||||||
|
if multiplayer.is_server():
|
||||||
|
round_timer -= delta
|
||||||
|
time_remaining_changed.emit(round_timer)
|
||||||
|
rpc("sync_time_remaining", round_timer)
|
||||||
|
|
||||||
|
if round_timer <= 0:
|
||||||
|
_on_round_time_expired()
|
||||||
|
|
||||||
|
phase_timer -= delta
|
||||||
|
if phase_timer <= 0:
|
||||||
|
phase_timer = phase_interval
|
||||||
|
_shrink_arena()
|
||||||
|
|
||||||
|
if flood_cooldown > 0:
|
||||||
|
|
||||||
|
flood_cooldown -= delta
|
||||||
|
|
||||||
|
if multiplayer.is_server():
|
||||||
|
if flood_cooldown <= 0 and bull_node:
|
||||||
|
var bull_pos_3d = gridmap.local_to_map(bull_node.position)
|
||||||
|
var bull_pos_2d = Vector2i(bull_pos_3d.x, bull_pos_3d.z)
|
||||||
|
if _is_boundary(bull_pos_2d):
|
||||||
|
_trigger_water_flood()
|
||||||
|
|
||||||
|
|
||||||
|
candy_tick_timer -= delta
|
||||||
|
if candy_tick_timer <= 0:
|
||||||
|
candy_tick_timer = 0.1
|
||||||
|
_process_candy_tick()
|
||||||
|
|
||||||
|
for pid in player_cooldowns.keys():
|
||||||
|
if player_cooldowns[pid] > 0:
|
||||||
|
player_cooldowns[pid] -= delta
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_time_remaining(time: float) -> void:
|
||||||
|
round_timer = time
|
||||||
|
time_remaining_changed.emit(round_timer)
|
||||||
|
|
||||||
|
func _on_round_time_expired() -> void:
|
||||||
|
is_active = false
|
||||||
|
var players = get_tree().get_nodes_in_group("Players")
|
||||||
|
for p in players:
|
||||||
|
if p.is_in_group("Players"):
|
||||||
|
var pid = p.name.to_int()
|
||||||
|
if not elimination_order.has(pid):
|
||||||
|
elimination_order.append(pid)
|
||||||
|
player_placement[pid] = elimination_order.size()
|
||||||
|
|
||||||
|
_end_round()
|
||||||
|
|
||||||
|
func _trigger_water_flood() -> void:
|
||||||
|
flood_cooldown = 3.0
|
||||||
|
print("[MektonBulls] Bull is on boundary! Flooding outer ring!")
|
||||||
|
|
||||||
|
# Network sync
|
||||||
|
rpc("sync_water_flood")
|
||||||
|
_apply_water_flood()
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_water_flood() -> void:
|
||||||
|
_apply_water_flood()
|
||||||
|
|
||||||
|
func _apply_water_flood() -> void:
|
||||||
|
# 1. Eliminate any player whose cell is currently _is_boundary
|
||||||
|
# (which is the outermost ring of the current arena_size)
|
||||||
|
var players = get_tree().get_nodes_in_group("Players")
|
||||||
|
var bounds = arena_size_for_phase(current_phase)
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
if p.is_in_group("Players") and p.has_method("is_eliminated") and not p.is_eliminated():
|
||||||
|
var p_cell_3d = gridmap.local_to_map(p.position)
|
||||||
|
var p_cell_2d = Vector2i(p_cell_3d.x, p_cell_3d.z)
|
||||||
|
if _is_boundary(p_cell_2d):
|
||||||
|
if multiplayer.is_server():
|
||||||
|
if p.has_method("eliminate"):
|
||||||
|
p.eliminate()
|
||||||
|
else:
|
||||||
|
player_eliminated.emit(p.name.to_int())
|
||||||
|
|
||||||
|
# 2. Play VFX / SFX (placeholder print if no actual scene yet)
|
||||||
|
if main_scene and main_scene.get("vfx_manager") and main_scene.vfx_manager.has_method("play_splash"):
|
||||||
|
var cx = (bounds.x / 2.0) * gridmap.cell_size.x
|
||||||
|
var cz = (bounds.y / 2.0) * gridmap.cell_size.z
|
||||||
|
main_scene.vfx_manager.play_splash(Vector3(cx, 0, cz))
|
||||||
|
|
||||||
|
if has_node("/root/SfxManager"):
|
||||||
|
get_node("/root/SfxManager").play_rpc("water_flood")
|
||||||
|
else:
|
||||||
|
print("[MektonBulls] WHOOSH! Water flood VFX played.")
|
||||||
|
|
||||||
|
# 3. Set those cells to TILE_WATER
|
||||||
|
for x in range(20):
|
||||||
|
for z in range(20):
|
||||||
|
var pos = Vector2i(x, z)
|
||||||
|
if _is_boundary(pos):
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WATER)
|
||||||
|
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
|
||||||
|
arena_cells[pos] = CellState.WATER
|
||||||
|
|
||||||
|
gridmap.update_grid_data()
|
||||||
|
|
||||||
|
func _assign_initial_blueprints() -> void:
|
||||||
|
var players = get_tree().get_nodes_in_group("Players")
|
||||||
|
for p in players:
|
||||||
|
if p.is_in_group("Players"):
|
||||||
|
_reroll_blueprint(p.name.to_int())
|
||||||
|
|
||||||
|
func _reroll_blueprint(player_id: int) -> void:
|
||||||
|
var bp = Blueprint3x3.new()
|
||||||
|
bp.color = GOAL_COLORS[randi() % GOAL_COLORS.size()]
|
||||||
|
bp.anchor = _get_valid_3x3_anchor()
|
||||||
|
|
||||||
|
# Generate 3x3 cells
|
||||||
|
for dx in range(3):
|
||||||
|
for dz in range(3):
|
||||||
|
var cpos = Vector2i(bp.anchor.x + dx, bp.anchor.y + dz)
|
||||||
|
bp.cells.append(cpos)
|
||||||
|
# Paint it
|
||||||
|
gridmap.set_cell_item(Vector3i(cpos.x, 0, cpos.y), bp.color)
|
||||||
|
|
||||||
|
player_blueprints[player_id] = bp
|
||||||
|
gridmap.update_grid_data()
|
||||||
|
rpc("sync_painted_cells", bp.cells, bp.color)
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_painted_cells(cells: Array, color: int) -> void:
|
||||||
|
for c in cells:
|
||||||
|
gridmap.set_cell_item(Vector3i(c.x, 0, c.y), color)
|
||||||
|
|
||||||
|
func _get_valid_3x3_anchor() -> Vector2i:
|
||||||
|
var bounds = arena_size_for_phase(current_phase)
|
||||||
|
# boundary is 0 and bounds-1
|
||||||
|
# inner is 1 to bounds-2
|
||||||
|
# 3x3 needs x to x+2 -> x+2 <= bounds-2 -> x <= bounds-4
|
||||||
|
var max_x = bounds.x - 4
|
||||||
|
var max_y = bounds.y - 4
|
||||||
|
|
||||||
|
if max_x < 1: max_x = 1
|
||||||
|
if max_y < 1: max_y = 1
|
||||||
|
|
||||||
|
return Vector2i(randi_range(1, max_x), randi_range(1, max_y))
|
||||||
|
|
||||||
|
func _process_candy_tick() -> void:
|
||||||
|
var players = get_tree().get_nodes_in_group("Players")
|
||||||
|
for p in players:
|
||||||
|
if not p.is_in_group("Players") or (p.has_method("is_eliminated") and p.is_eliminated()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
var pid = p.name.to_int()
|
||||||
|
if not player_blueprints.has(pid):
|
||||||
|
continue
|
||||||
|
|
||||||
|
var bp: Blueprint3x3 = player_blueprints[pid]
|
||||||
|
var pos_3d = gridmap.local_to_map(p.position)
|
||||||
|
var pos = Vector2i(pos_3d.x, pos_3d.z)
|
||||||
|
|
||||||
|
if pos in bp.cells:
|
||||||
|
var item = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y))
|
||||||
|
if item == bp.color:
|
||||||
|
# Pickup!
|
||||||
|
bp.progress += 1
|
||||||
|
# Remove color local
|
||||||
|
gridmap.set_cell_item(Vector3i(pos.x, 0, pos.y), TILE_WALKABLE)
|
||||||
|
rpc("sync_painted_cells", [pos], TILE_WALKABLE)
|
||||||
|
|
||||||
|
if bp.progress >= 9:
|
||||||
|
_grant_power(pid)
|
||||||
|
_reroll_blueprint(pid)
|
||||||
|
|
||||||
|
|
||||||
|
func _grant_power(player_id: int) -> void:
|
||||||
|
print("[MektonBulls] Blueprint complete! Prompting power picker for ", player_id)
|
||||||
|
# Safely call rpc_id
|
||||||
|
if multiplayer.has_multiplayer_peer() and multiplayer.get_peers().has(player_id) or player_id == multiplayer.get_unique_id():
|
||||||
|
rpc_id(player_id, "prompt_power_picker")
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func prompt_power_picker() -> void:
|
||||||
|
if hud_node and hud_node.has_method("show_power_picker"):
|
||||||
|
hud_node.show_power_picker()
|
||||||
|
|
||||||
|
var hud_node: Node = null
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_score_completed(placements: Dictionary) -> void:
|
||||||
|
if hud_node and hud_node.has_method("show_placement"):
|
||||||
|
hud_node.show_placement(placements)
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_local", "reliable")
|
||||||
|
func try_pick_power(power_type_str: String) -> void:
|
||||||
|
var sender = multiplayer.get_remote_sender_id()
|
||||||
|
if not multiplayer.is_server(): return
|
||||||
|
|
||||||
|
if not player_powers.has(sender):
|
||||||
|
player_powers[sender] = { "FREEZE": 0, "KNOCK": 0 }
|
||||||
|
|
||||||
|
if power_type_str == "FREEZE" or power_type_str == "KNOCK":
|
||||||
|
player_powers[sender][power_type_str] += 1
|
||||||
|
rpc("sync_player_powers", sender, player_powers[sender])
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_player_powers(pid: int, powers: Dictionary) -> void:
|
||||||
|
player_powers[pid] = powers
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_local", "reliable")
|
||||||
|
func try_use_freeze() -> void:
|
||||||
|
var sender = multiplayer.get_remote_sender_id()
|
||||||
|
if not multiplayer.is_server(): return
|
||||||
|
|
||||||
|
if player_cooldowns.get(sender, 0.0) > 0: return
|
||||||
|
if not player_powers.has(sender) or player_powers[sender]["FREEZE"] <= 0: return
|
||||||
|
|
||||||
|
player_powers[sender]["FREEZE"] -= 1
|
||||||
|
player_cooldowns[sender] = 1.0
|
||||||
|
rpc("sync_player_powers", sender, player_powers[sender])
|
||||||
|
|
||||||
|
if bull_node and bull_node.has_method("apply_slow"):
|
||||||
|
bull_node.apply_slow(3.0)
|
||||||
|
if has_node("/root/SfxManager"):
|
||||||
|
get_node("/root/SfxManager").play_rpc("freeze_burst")
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_local", "reliable")
|
||||||
|
func try_use_knock(target_id: int, dir: Vector3) -> void:
|
||||||
|
var sender = multiplayer.get_remote_sender_id()
|
||||||
|
if not multiplayer.is_server(): return
|
||||||
|
|
||||||
|
if player_cooldowns.get(sender, 0.0) > 0: return
|
||||||
|
if not player_powers.has(sender) or player_powers[sender]["KNOCK"] <= 0: return
|
||||||
|
|
||||||
|
player_powers[sender]["KNOCK"] -= 1
|
||||||
|
player_cooldowns[sender] = 1.0
|
||||||
|
rpc("sync_player_powers", sender, player_powers[sender])
|
||||||
|
|
||||||
|
rpc("sync_apply_knock", target_id, dir)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func sync_apply_knock(target_id: int, dir: Vector3) -> void:
|
||||||
|
var players = get_tree().get_nodes_in_group("Players")
|
||||||
|
for p in players:
|
||||||
|
if p.name == str(target_id):
|
||||||
|
# Translate position 1 cell in dir
|
||||||
|
var move_dist = gridmap.cell_size.x
|
||||||
|
# Normalize to 4 directions
|
||||||
|
var dx = 0
|
||||||
|
var dz = 0
|
||||||
|
if abs(dir.x) > abs(dir.z):
|
||||||
|
dx = sign(dir.x)
|
||||||
|
else:
|
||||||
|
dz = sign(dir.z)
|
||||||
|
|
||||||
|
p.position += Vector3(dx * move_dist, 0, dz * move_dist)
|
||||||
|
|
||||||
|
# Play a smack sound if available
|
||||||
|
if has_node("/root/SfxManager"):
|
||||||
|
get_node("/root/SfxManager").play_rpc("knock_burst")
|
||||||
|
elif main_scene and main_scene.get("audio_manager") and main_scene.audio_manager.has_method("play_sfx"):
|
||||||
|
main_scene.audio_manager.play_sfx("smack")
|
||||||
|
|
||||||
|
|
||||||
|
func _validate_blueprint_after_shrink() -> void:
|
||||||
|
if not multiplayer.is_server(): return
|
||||||
|
var bounds = arena_size_for_phase(current_phase)
|
||||||
|
|
||||||
|
for pid in player_blueprints.keys():
|
||||||
|
var bp: Blueprint3x3 = player_blueprints[pid]
|
||||||
|
var outside = false
|
||||||
|
for c in bp.cells:
|
||||||
|
if c.x <= 0 or c.x >= bounds.x - 1 or c.y <= 0 or c.y >= bounds.y - 1:
|
||||||
|
outside = true
|
||||||
|
break
|
||||||
|
|
||||||
|
if outside:
|
||||||
|
# Clear old ones
|
||||||
|
rpc("sync_painted_cells", bp.cells, TILE_WALKABLE)
|
||||||
|
_reroll_blueprint(pid)
|
||||||
|
|
||||||
|
func _on_player_eliminated(player_id: int) -> void:
|
||||||
|
if not elimination_order.has(player_id):
|
||||||
|
elimination_order.append(player_id)
|
||||||
|
print("[MektonBulls] Player %d eliminated. Rank: %d" % [player_id, elimination_order.size()])
|
||||||
|
# Placement rank: 1 is first out
|
||||||
|
player_placement[player_id] = elimination_order.size()
|
||||||
|
|
||||||
|
if multiplayer.is_server():
|
||||||
|
# Check if only 1 player remains
|
||||||
|
var players = get_tree().get_nodes_in_group("Players")
|
||||||
|
var alive_count = 0
|
||||||
|
var last_alive: int = -1
|
||||||
|
for p in players:
|
||||||
|
if p.is_in_group("Players"):
|
||||||
|
var pid = p.name.to_int()
|
||||||
|
if not elimination_order.has(pid):
|
||||||
|
alive_count += 1
|
||||||
|
last_alive = pid
|
||||||
|
|
||||||
|
if alive_count <= 1:
|
||||||
|
if last_alive != -1 and not elimination_order.has(last_alive):
|
||||||
|
elimination_order.append(last_alive)
|
||||||
|
player_placement[last_alive] = elimination_order.size()
|
||||||
|
is_active = false
|
||||||
|
_end_round()
|
||||||
|
|
||||||
|
func _end_round() -> void:
|
||||||
|
if not multiplayer.is_server(): return
|
||||||
|
print("[MektonBulls] Round ended. Computing placement scores...")
|
||||||
|
|
||||||
|
var total_players = elimination_order.size()
|
||||||
|
if total_players == 0: return
|
||||||
|
|
||||||
|
var min_pts = LobbyManager.mekton_bulls_min_points
|
||||||
|
var max_pts = LobbyManager.mekton_bulls_max_points
|
||||||
|
|
||||||
|
var scores = {}
|
||||||
|
for i in range(total_players):
|
||||||
|
var pid = elimination_order[i]
|
||||||
|
var rank = i + 1 # 1 = first out
|
||||||
|
|
||||||
|
var pts = min_pts
|
||||||
|
if total_players > 1:
|
||||||
|
var t = float(rank - 1) / float(total_players - 1)
|
||||||
|
pts = int(lerp(float(min_pts), float(max_pts), t))
|
||||||
|
|
||||||
|
scores[pid] = pts
|
||||||
|
print("[MektonBulls] Player %d finished rank %d -> %d pts" % [pid, rank, pts])
|
||||||
|
|
||||||
|
# In the real game, we'd sync this to the scores manager
|
||||||
|
# main_scene.rpc("sync_score_updated", scores) - wait, is there a direct scoreboard in Mekton Bulls?
|
||||||
|
# Typically GoalsCycleManager tracks scores, or main.gd
|
||||||
|
if main_scene and main_scene.get("goals_cycle_manager"):
|
||||||
|
for pid in scores.keys():
|
||||||
|
main_scene.goals_cycle_manager.add_score(pid, scores[pid])
|
||||||
|
|
||||||
|
rpc("sync_score_completed", player_placement)
|
||||||
|
|
||||||
|
# End the goal cycle match if it hasn't already
|
||||||
|
if main_scene and main_scene.get("goals_cycle_manager") and main_scene.goals_cycle_manager.is_active:
|
||||||
|
main_scene.goals_cycle_manager.end_match()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bnsxsqvj2ea7f
|
||||||
@@ -155,15 +155,19 @@ func simple_move_to(grid_position: Vector2i) -> bool:
|
|||||||
player.knock_tekton()
|
player.knock_tekton()
|
||||||
return false # Don't move into the tile, just knock
|
return false # Don't move into the tile, just knock
|
||||||
|
|
||||||
# If moving into a sticky cell: block movement unless player is in ghost
|
# If moving into a sticky cell: slow the player (unless cleanser active,
|
||||||
# mode (is_invisible), which lets them bypass sticky tiles in gauntlet.
|
# which clears the cell instead). Sticky no longer hard-traps.
|
||||||
if gm and gm.is_active and gm.is_sticky_cell(grid_position):
|
if gm and gm.is_active and gm.is_sticky_cell(grid_position):
|
||||||
if player.get("is_invisible"):
|
var pid = player.get("peer_id") if "peer_id" in player else -1
|
||||||
# Ghost mode: walk through sticky tile freely
|
if pid != -1 and gm.is_cleanser_active(pid):
|
||||||
print("[Move] Ghost mode bypassed sticky cell at %s" % grid_position)
|
# Cleanser immunity: clear sticky cell, use one cell, don't slow
|
||||||
|
gm.clear_sticky_cell(grid_position)
|
||||||
|
gm.use_cleanser_cell(pid)
|
||||||
|
print("[Move] Cleanser cleared sticky cell at %s (%d cells left)" % [grid_position, gm.cleanser_cells_left.get(pid, 0)])
|
||||||
else:
|
else:
|
||||||
print("[Move] Failed: Blocked by Gauntlet Sticky cell at %s" % grid_position)
|
print("[Move] Player stepping into sticky cell at %s — slowed" % grid_position)
|
||||||
return false
|
if player.is_multiplayer_authority() or multiplayer.is_server():
|
||||||
|
gm.apply_sticky_slow(player)
|
||||||
|
|
||||||
rotate_towards_target(grid_position)
|
rotate_towards_target(grid_position)
|
||||||
|
|
||||||
@@ -338,9 +342,12 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
|
|||||||
if main_sticky and main_sticky.get("gauntlet_manager"):
|
if main_sticky and main_sticky.get("gauntlet_manager"):
|
||||||
var gm_sticky = main_sticky.gauntlet_manager
|
var gm_sticky = main_sticky.gauntlet_manager
|
||||||
if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos):
|
if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos):
|
||||||
if other_player.get("is_invisible"):
|
var push_pid = other_player.get("peer_id") if "peer_id" in other_player else -1
|
||||||
# Ghost mode: pushed player bypasses sticky
|
if push_pid != -1 and gm_sticky.is_cleanser_active(push_pid):
|
||||||
print("[Move] Ghost mode bypassed push-into-sticky at %s" % pushed_to_pos)
|
# Cleanser immunity: clear sticky cell, use one cell
|
||||||
|
gm_sticky.clear_sticky_cell(pushed_to_pos)
|
||||||
|
gm_sticky.use_cleanser_cell(push_pid)
|
||||||
|
print("[Move] Cleanser cleared push-into-sticky at %s" % pushed_to_pos)
|
||||||
else:
|
else:
|
||||||
print("[Move] Player pushed into sticky cell at %s — slowed" % pushed_to_pos)
|
print("[Move] Player pushed into sticky cell at %s — slowed" % pushed_to_pos)
|
||||||
if multiplayer.is_server() or other_player.is_multiplayer_authority():
|
if multiplayer.is_server() or other_player.is_multiplayer_authority():
|
||||||
@@ -400,6 +407,17 @@ func _on_movement_finished():
|
|||||||
emit_signal("movement_finished")
|
emit_signal("movement_finished")
|
||||||
else:
|
else:
|
||||||
current_move_direction = Vector2i.ZERO
|
current_move_direction = Vector2i.ZERO
|
||||||
|
# Gauntlet (#072): a Cleanser ends early once the player rests on a safe
|
||||||
|
# cell. Gated on gm.is_active so other game modes are never affected.
|
||||||
|
var gm = null
|
||||||
|
var main_node = player.get_tree().root.get_node_or_null("Main")
|
||||||
|
if main_node and main_node.get("gauntlet_manager"):
|
||||||
|
gm = main_node.gauntlet_manager
|
||||||
|
if gm and gm.is_active and player.get("current_position") != null:
|
||||||
|
var mpid = player.get("peer_id") if "peer_id" in player else -1
|
||||||
|
if mpid != -1 and gm.is_cleanser_active(mpid):
|
||||||
|
if multiplayer.is_server() or player.is_multiplayer_authority():
|
||||||
|
gm.notify_movement_stopped(mpid, player.current_position)
|
||||||
emit_signal("movement_finished")
|
emit_signal("movement_finished")
|
||||||
|
|
||||||
func move_to_clicked_position(grid_position: Vector2i) -> bool:
|
func move_to_clicked_position(grid_position: Vector2i) -> bool:
|
||||||
|
|||||||
@@ -547,11 +547,9 @@ func spawn_powerups_around(center: Vector2i, force_powerups: bool = true, only_c
|
|||||||
# Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request)
|
# Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request)
|
||||||
item_id = rng.randi_range(7, 10)
|
item_id = rng.randi_range(7, 10)
|
||||||
elif LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET):
|
elif LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET):
|
||||||
# Gauntlet mode: mostly common tiles, but ghost (14) can spawn too.
|
# Gauntlet mode: No power-up tile spawns from world.
|
||||||
if rng.randf() < 0.85:
|
# Only common tiles (7-10) spawn; Smack/Cleanser are handled separately.
|
||||||
item_id = rng.randi_range(7, 10)
|
item_id = rng.randi_range(7, 10)
|
||||||
else:
|
|
||||||
item_id = 14 # Ghost powerup only
|
|
||||||
else:
|
else:
|
||||||
# Other modes: 80% Chance for Common Tile (7-10), 20% for PowerUp
|
# Other modes: 80% Chance for Common Tile (7-10), 20% for PowerUp
|
||||||
if rng.randf() < 0.8:
|
if rng.randf() < 0.8:
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
extends Area3D
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
ROAM,
|
||||||
|
CHARGE,
|
||||||
|
COOLDOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
var current_state: State = State.ROAM
|
||||||
|
var gridmap: Node
|
||||||
|
var arena_manager: Node
|
||||||
|
|
||||||
|
var move_speed: float = 3.0
|
||||||
|
var charge_speed: float = 12.0
|
||||||
|
var target_pos: Vector3
|
||||||
|
var state_timer: float = 0.0
|
||||||
|
var slow_timer: float = 0.0
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
add_to_group("MektonBulls")
|
||||||
|
body_entered.connect(_on_body_entered)
|
||||||
|
|
||||||
|
func initialize(manager: Node, grid: Node, start_pos: Vector3):
|
||||||
|
arena_manager = manager
|
||||||
|
gridmap = grid
|
||||||
|
position = start_pos
|
||||||
|
_pick_roam_target()
|
||||||
|
|
||||||
|
func apply_slow(duration: float) -> void:
|
||||||
|
slow_timer = duration
|
||||||
|
|
||||||
|
func _physics_process(delta: float):
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
|
||||||
|
state_timer -= delta
|
||||||
|
if slow_timer > 0:
|
||||||
|
slow_timer -= delta
|
||||||
|
|
||||||
|
match current_state:
|
||||||
|
State.ROAM:
|
||||||
|
_process_roam(delta)
|
||||||
|
if state_timer <= 0:
|
||||||
|
_try_start_charge()
|
||||||
|
State.CHARGE:
|
||||||
|
_process_charge(delta)
|
||||||
|
State.COOLDOWN:
|
||||||
|
if state_timer <= 0:
|
||||||
|
current_state = State.ROAM
|
||||||
|
_pick_roam_target()
|
||||||
|
|
||||||
|
func _process_roam(delta: float):
|
||||||
|
var dist = position.distance_to(target_pos)
|
||||||
|
if dist < 0.1:
|
||||||
|
_pick_roam_target()
|
||||||
|
else:
|
||||||
|
var dir = (target_pos - position).normalized()
|
||||||
|
dir.y = 0
|
||||||
|
if dir.length_squared() > 0:
|
||||||
|
var actual_speed = move_speed
|
||||||
|
if slow_timer > 0: actual_speed *= 0.5
|
||||||
|
position += dir * actual_speed * delta
|
||||||
|
# Face direction implicitly
|
||||||
|
var look_target = position + dir
|
||||||
|
look_target.y = position.y
|
||||||
|
if position.distance_squared_to(look_target) > 0.01:
|
||||||
|
look_at(look_target, Vector3.UP)
|
||||||
|
|
||||||
|
func _pick_roam_target():
|
||||||
|
if not arena_manager: return
|
||||||
|
|
||||||
|
var bounds = arena_manager.arena_size_for_phase(arena_manager.current_phase)
|
||||||
|
# Random walkable position within bounds
|
||||||
|
# We know 0,0 is boundary, bounds.x-1 is boundary
|
||||||
|
var min_x = 1
|
||||||
|
var max_x = bounds.x - 2
|
||||||
|
var min_z = 1
|
||||||
|
var max_z = bounds.y - 2
|
||||||
|
|
||||||
|
if min_x > max_x: max_x = min_x
|
||||||
|
if min_z > max_z: max_z = min_z
|
||||||
|
|
||||||
|
# Avoid exact center (where the static delivery target presumably sits)
|
||||||
|
var cx = int(bounds.x / 2.0)
|
||||||
|
var cz = int(bounds.y / 2.0)
|
||||||
|
|
||||||
|
var rx = cx
|
||||||
|
var rz = cz
|
||||||
|
while rx == cx and rz == cz:
|
||||||
|
rx = randi_range(min_x, max_x)
|
||||||
|
rz = randi_range(min_z, max_z)
|
||||||
|
|
||||||
|
var world_x = rx * gridmap.cell_size.x + gridmap.cell_size.x / 2.0
|
||||||
|
var world_z = rz * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
|
||||||
|
|
||||||
|
target_pos = Vector3(world_x, position.y, world_z)
|
||||||
|
state_timer = randf_range(2.0, 4.0)
|
||||||
|
|
||||||
|
func _try_start_charge():
|
||||||
|
# Find closest player
|
||||||
|
var players = get_tree().get_nodes_in_group("Players")
|
||||||
|
var closest_player = null
|
||||||
|
var min_dist = INF
|
||||||
|
|
||||||
|
for p in players:
|
||||||
|
if p.is_in_group("Players") and p.has_method("is_eliminated") and not p.is_eliminated():
|
||||||
|
var d = position.distance_to(p.position)
|
||||||
|
if d < min_dist and d < 15.0: # Range check
|
||||||
|
min_dist = d
|
||||||
|
closest_player = p
|
||||||
|
|
||||||
|
if closest_player:
|
||||||
|
current_state = State.CHARGE
|
||||||
|
target_pos = closest_player.position
|
||||||
|
target_pos.y = position.y
|
||||||
|
state_timer = 2.0 # Max charge duration
|
||||||
|
if has_node("/root/SfxManager"):
|
||||||
|
get_node("/root/SfxManager").play_rpc("bull_charge")
|
||||||
|
else:
|
||||||
|
_pick_roam_target()
|
||||||
|
|
||||||
|
func _process_charge(delta: float):
|
||||||
|
var dir = (target_pos - position).normalized()
|
||||||
|
dir.y = 0
|
||||||
|
var actual_speed = charge_speed
|
||||||
|
if slow_timer > 0: actual_speed *= 0.5
|
||||||
|
position += dir * actual_speed * delta
|
||||||
|
|
||||||
|
var look_target = position + dir
|
||||||
|
look_target.y = position.y
|
||||||
|
if position.distance_squared_to(look_target) > 0.01:
|
||||||
|
look_at(look_target, Vector3.UP)
|
||||||
|
|
||||||
|
var dist = position.distance_to(target_pos)
|
||||||
|
if dist < 0.5 or state_timer <= 0:
|
||||||
|
# Hit destination or timeout
|
||||||
|
current_state = State.COOLDOWN
|
||||||
|
state_timer = 1.5
|
||||||
|
|
||||||
|
func _on_body_entered(body: Node3D):
|
||||||
|
if body.is_in_group("Players") and multiplayer.is_server():
|
||||||
|
# Knock them out
|
||||||
|
if body.has_method("eliminate"):
|
||||||
|
body.eliminate()
|
||||||
|
else:
|
||||||
|
print("[MektonBull] Knocked out player", body.name)
|
||||||
|
# Dispatch via manager
|
||||||
|
if arena_manager and arena_manager.has_signal("player_eliminated"):
|
||||||
|
arena_manager.player_eliminated.emit(body.name.to_int())
|
||||||
|
|
||||||
|
# Polish: SFX + Camera Shake
|
||||||
|
rpc("sync_bull_impact")
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "unreliable")
|
||||||
|
func sync_bull_impact() -> void:
|
||||||
|
if has_node("/root/SfxManager"):
|
||||||
|
get_node("/root/SfxManager").play("bull_impact")
|
||||||
|
|
||||||
|
var root = get_tree().root
|
||||||
|
var main = root.get_node_or_null("Main")
|
||||||
|
if main and main.get("screen_shake_manager"):
|
||||||
|
main.screen_shake_manager.shake(0.2, 0.5)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ciytpot4av5gw
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
@onready var bull_tracker = $BullTracker
|
||||||
|
@onready var power_picker = $PowerPicker
|
||||||
|
@onready var placement_panel = $PlacementPanel
|
||||||
|
@onready var placement_list = $PlacementPanel/VBoxContainer/List
|
||||||
|
@onready var counters_lbl = $PowerCounters/Label
|
||||||
|
|
||||||
|
var local_pid: int = -1
|
||||||
|
var arena_manager: Node
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
power_picker.hide()
|
||||||
|
placement_panel.hide()
|
||||||
|
|
||||||
|
func set_local_player(pid: int):
|
||||||
|
local_pid = pid
|
||||||
|
|
||||||
|
func initialize(manager: Node):
|
||||||
|
arena_manager = manager
|
||||||
|
|
||||||
|
func _process(delta: float):
|
||||||
|
# Hide tracker if bull is close/visible, else point to it
|
||||||
|
if not arena_manager: return
|
||||||
|
|
||||||
|
var local_powers = arena_manager.player_powers.get(local_pid, {"FREEZE": 0, "KNOCK": 0})
|
||||||
|
counters_lbl.text = "Freeze: %d | Knock: %d" % [local_powers.get("FREEZE", 0), local_powers.get("KNOCK", 0)]
|
||||||
|
|
||||||
|
func show_power_picker():
|
||||||
|
power_picker.show()
|
||||||
|
|
||||||
|
func _on_freeze_btn_pressed():
|
||||||
|
if arena_manager:
|
||||||
|
arena_manager.rpc_id(1, "try_pick_power", "FREEZE")
|
||||||
|
power_picker.hide()
|
||||||
|
|
||||||
|
func _on_knock_btn_pressed():
|
||||||
|
if arena_manager:
|
||||||
|
arena_manager.rpc_id(1, "try_pick_power", "KNOCK")
|
||||||
|
power_picker.hide()
|
||||||
|
|
||||||
|
func show_placement(scores: Dictionary):
|
||||||
|
placement_panel.show()
|
||||||
|
for child in placement_list.get_children():
|
||||||
|
child.queue_free()
|
||||||
|
|
||||||
|
var items = []
|
||||||
|
for pid in scores.keys():
|
||||||
|
items.append({"pid": pid, "rank": scores[pid]})
|
||||||
|
|
||||||
|
items.sort_custom(func(a, b): return a.rank < b.rank)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
var lbl = Label.new()
|
||||||
|
lbl.text = "Player %s - Rank %s" % [str(item.pid), str(item.rank)]
|
||||||
|
placement_list.add_child(lbl)
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://h2uragoekxs1
|
||||||
Reference in New Issue
Block a user