diff --git a/.gitattributes b/.gitattributes
index c68a711..e09c6c3 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,6 +1,5 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
-
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.cs text diff=csharp
@@ -14,11 +13,9 @@
*.yaml text
*.csproj text
*.sln text
-
# Declare files that will always have CRLF line endings on checkout.
*.bat text eol=crlf
*.ps1 text eol=crlf
-
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
@@ -40,3 +37,4 @@
*.7z binary
*.import binary
*.uid binary
+*.glb filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitea/workflows/deploy-auth.yml b/.gitea/workflows/deploy-auth.yml
index 303c271..2d39408 100644
--- a/.gitea/workflows/deploy-auth.yml
+++ b/.gitea/workflows/deploy-auth.yml
@@ -1,80 +1,80 @@
-name: Deploy Promiscuity Auth API
-
-on:
- push:
- branches:
- - main
- workflow_dispatch: {}
-
-jobs:
- deploy:
- runs-on: self-hosted
-
- env:
- IMAGE_NAME: promiscuity-auth:latest
- IMAGE_TAR: /tmp/promiscuity-auth.tar
- # All nodes that might run the pod (control-plane + workers)
- NODES: "192.168.86.72 192.168.86.73 192.168.86.74"
-
- steps:
- - name: Checkout repo
- uses: actions/checkout@v4
-
- # -----------------------------
- # Build Docker image
- # -----------------------------
- - name: Build Docker image
- run: |
- cd microservices/AuthApi
- docker build -t "${IMAGE_NAME}" .
-
- # -----------------------------
- # Save image as TAR on runner
- # -----------------------------
- - name: Save Docker image to TAR
- run: |
- docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}"
-
- # -----------------------------
- # Copy TAR to each Kubernetes node
- # -----------------------------
- - name: Copy TAR to nodes
- run: |
- for node in ${NODES}; do
- echo "Copying image tar to $node ..."
- scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/promiscuity-auth.tar
- done
-
- # -----------------------------
- # Import image into containerd on each node
- # -----------------------------
- - name: Import image on nodes
- run: |
- for node in ${NODES}; do
- echo "Importing image on $node ..."
- ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/promiscuity-auth.tar"
- done
-
- # -----------------------------
- # CLEANUP: delete TAR from nodes
- # -----------------------------
- - name: Clean TAR from nodes
- run: |
- for node in ${NODES}; do
- echo "Removing image tar on $node ..."
- ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-auth.tar"
- done
-
- # -----------------------------
- # CLEANUP: delete TAR from runner
- # -----------------------------
- - name: Clean TAR on runner
- run: |
- rm -f "${IMAGE_TAR}"
-
- # -----------------------------
- # Write kubeconfig from secret
- # -----------------------------
+name: Deploy Promiscuity Auth API
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch: {}
+
+jobs:
+ deploy:
+ runs-on: self-hosted
+
+ env:
+ IMAGE_NAME: promiscuity-auth:latest
+ IMAGE_TAR: /tmp/promiscuity-auth.tar
+ # All nodes that might run the pod (control-plane + workers)
+ NODES: "192.168.86.72 192.168.86.73 192.168.86.74"
+
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v4
+
+ # -----------------------------
+ # Build Docker image
+ # -----------------------------
+ - name: Build Docker image
+ run: |
+ cd microservices/AuthApi
+ docker build -t "${IMAGE_NAME}" .
+
+ # -----------------------------
+ # Save image as TAR on runner
+ # -----------------------------
+ - name: Save Docker image to TAR
+ run: |
+ docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}"
+
+ # -----------------------------
+ # Copy TAR to each Kubernetes node
+ # -----------------------------
+ - name: Copy TAR to nodes
+ run: |
+ for node in ${NODES}; do
+ echo "Copying image tar to $node ..."
+ scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/promiscuity-auth.tar
+ done
+
+ # -----------------------------
+ # Import image into containerd on each node
+ # -----------------------------
+ - name: Import image on nodes
+ run: |
+ for node in ${NODES}; do
+ echo "Importing image on $node ..."
+ ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/promiscuity-auth.tar"
+ done
+
+ # -----------------------------
+ # CLEANUP: delete TAR from nodes
+ # -----------------------------
+ - name: Clean TAR from nodes
+ run: |
+ for node in ${NODES}; do
+ echo "Removing image tar on $node ..."
+ ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-auth.tar"
+ done
+
+ # -----------------------------
+ # CLEANUP: delete TAR from runner
+ # -----------------------------
+ - name: Clean TAR on runner
+ run: |
+ rm -f "${IMAGE_TAR}"
+
+ # -----------------------------
+ # Write kubeconfig from secret
+ # -----------------------------
- name: Write kubeconfig from secret
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
@@ -95,19 +95,19 @@ jobs:
# Apply Kubernetes manifests
# (You create these files in your repo)
# -----------------------------
- - name: Apply Auth deployment & service
- env:
- KUBECONFIG: /tmp/kube/config
- run: |
- kubectl apply -f microservices/AuthApi/k8s/deployment.yaml -n promiscuity-auth
- kubectl apply -f microservices/AuthApi/k8s/service.yaml -n promiscuity-auth
-
- # -----------------------------
- # Rollout restart & wait
- # -----------------------------
- - name: Restart Auth deployment
- env:
- KUBECONFIG: /tmp/kube/config
- run: |
- kubectl rollout restart deployment/promiscuity-auth -n promiscuity-auth
- kubectl rollout status deployment/promiscuity-auth -n promiscuity-auth
+ - name: Apply Auth deployment & service
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl apply -f microservices/AuthApi/k8s/deployment.yaml -n promiscuity-auth
+ kubectl apply -f microservices/AuthApi/k8s/service.yaml -n promiscuity-auth
+
+ # -----------------------------
+ # Rollout restart & wait
+ # -----------------------------
+ - name: Restart Auth deployment
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl rollout restart deployment/promiscuity-auth -n promiscuity-auth
+ kubectl rollout status deployment/promiscuity-auth -n promiscuity-auth
diff --git a/.gitea/workflows/deploy-character.yml b/.gitea/workflows/deploy-character.yml
index 66a8dc0..d3ff77d 100644
--- a/.gitea/workflows/deploy-character.yml
+++ b/.gitea/workflows/deploy-character.yml
@@ -1,80 +1,80 @@
-name: Deploy Promiscuity Character API
-
-on:
- push:
- branches:
- - main
- workflow_dispatch: {}
-
-jobs:
- deploy:
- runs-on: self-hosted
-
- env:
- IMAGE_NAME: promiscuity-character:latest
- IMAGE_TAR: /tmp/promiscuity-character.tar
- # All nodes that might run the pod (control-plane + workers)
- NODES: "192.168.86.72 192.168.86.73 192.168.86.74"
-
- steps:
- - name: Checkout repo
- uses: actions/checkout@v4
-
- # -----------------------------
- # Build Docker image
- # -----------------------------
- - name: Build Docker image
- run: |
- cd microservices/CharacterApi
- docker build -t "${IMAGE_NAME}" .
-
- # -----------------------------
- # Save image as TAR on runner
- # -----------------------------
- - name: Save Docker image to TAR
- run: |
- docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}"
-
- # -----------------------------
- # Copy TAR to each Kubernetes node
- # -----------------------------
- - name: Copy TAR to nodes
- run: |
- for node in ${NODES}; do
- echo "Copying image tar to $node ..."
- scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/promiscuity-character.tar
- done
-
- # -----------------------------
- # Import image into containerd on each node
- # -----------------------------
- - name: Import image on nodes
- run: |
- for node in ${NODES}; do
- echo "Importing image on $node ..."
- ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/promiscuity-character.tar"
- done
-
- # -----------------------------
- # CLEANUP: delete TAR from nodes
- # -----------------------------
- - name: Clean TAR from nodes
- run: |
- for node in ${NODES}; do
- echo "Removing image tar on $node ..."
- ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-character.tar"
- done
-
- # -----------------------------
- # CLEANUP: delete TAR from runner
- # -----------------------------
- - name: Clean TAR on runner
- run: |
- rm -f "${IMAGE_TAR}"
-
- # -----------------------------
- # Write kubeconfig from secret
- # -----------------------------
+name: Deploy Promiscuity Character API
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch: {}
+
+jobs:
+ deploy:
+ runs-on: self-hosted
+
+ env:
+ IMAGE_NAME: promiscuity-character:latest
+ IMAGE_TAR: /tmp/promiscuity-character.tar
+ # All nodes that might run the pod (control-plane + workers)
+ NODES: "192.168.86.72 192.168.86.73 192.168.86.74"
+
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v4
+
+ # -----------------------------
+ # Build Docker image
+ # -----------------------------
+ - name: Build Docker image
+ run: |
+ cd microservices/CharacterApi
+ docker build -t "${IMAGE_NAME}" .
+
+ # -----------------------------
+ # Save image as TAR on runner
+ # -----------------------------
+ - name: Save Docker image to TAR
+ run: |
+ docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}"
+
+ # -----------------------------
+ # Copy TAR to each Kubernetes node
+ # -----------------------------
+ - name: Copy TAR to nodes
+ run: |
+ for node in ${NODES}; do
+ echo "Copying image tar to $node ..."
+ scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":/tmp/promiscuity-character.tar
+ done
+
+ # -----------------------------
+ # Import image into containerd on each node
+ # -----------------------------
+ - name: Import image on nodes
+ run: |
+ for node in ${NODES}; do
+ echo "Importing image on $node ..."
+ ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import /tmp/promiscuity-character.tar"
+ done
+
+ # -----------------------------
+ # CLEANUP: delete TAR from nodes
+ # -----------------------------
+ - name: Clean TAR from nodes
+ run: |
+ for node in ${NODES}; do
+ echo "Removing image tar on $node ..."
+ ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-character.tar"
+ done
+
+ # -----------------------------
+ # CLEANUP: delete TAR from runner
+ # -----------------------------
+ - name: Clean TAR on runner
+ run: |
+ rm -f "${IMAGE_TAR}"
+
+ # -----------------------------
+ # Write kubeconfig from secret
+ # -----------------------------
- name: Write kubeconfig from secret
env:
KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
@@ -94,19 +94,19 @@ jobs:
# -----------------------------
# Apply Kubernetes manifests
# -----------------------------
- - name: Apply Character deployment & service
- env:
- KUBECONFIG: /tmp/kube/config
- run: |
- kubectl apply -f microservices/CharacterApi/k8s/deployment.yaml -n promiscuity-character
- kubectl apply -f microservices/CharacterApi/k8s/service.yaml -n promiscuity-character
-
- # -----------------------------
- # Rollout restart & wait
- # -----------------------------
- - name: Restart Character deployment
- env:
- KUBECONFIG: /tmp/kube/config
- run: |
- kubectl rollout restart deployment/promiscuity-character -n promiscuity-character
- kubectl rollout status deployment/promiscuity-character -n promiscuity-character
+ - name: Apply Character deployment & service
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl apply -f microservices/CharacterApi/k8s/deployment.yaml -n promiscuity-character
+ kubectl apply -f microservices/CharacterApi/k8s/service.yaml -n promiscuity-character
+
+ # -----------------------------
+ # Rollout restart & wait
+ # -----------------------------
+ - name: Restart Character deployment
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl rollout restart deployment/promiscuity-character -n promiscuity-character
+ kubectl rollout status deployment/promiscuity-character -n promiscuity-character
diff --git a/.gitea/workflows/k8s-smoke-test.yml b/.gitea/workflows/k8s-smoke-test.yml
index 93eea21..53e7d60 100644
--- a/.gitea/workflows/k8s-smoke-test.yml
+++ b/.gitea/workflows/k8s-smoke-test.yml
@@ -1,27 +1,27 @@
-name: k8s smoke test
-
-on:
- push:
- branches:
- - main
- workflow_dispatch:
-
-jobs:
- test:
- runs-on: self-hosted
- steps:
- - name: Checkout repo
- uses: actions/checkout@v4
-
- - name: Write kubeconfig from secret
- env:
- KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
- run: |
- mkdir -p /tmp/kube
- printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
-
- - name: Test kubectl connectivity
- env:
- KUBECONFIG: /tmp/kube/config
- run: |
- kubectl get nodes --kubeconfig "${KUBECONFIG}"
+name: k8s smoke test
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: self-hosted
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v4
+
+ - name: Write kubeconfig from secret
+ env:
+ KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }}
+ run: |
+ mkdir -p /tmp/kube
+ printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
+
+ - name: Test kubectl connectivity
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl get nodes --kubeconfig "${KUBECONFIG}"
diff --git a/README.md b/README.md
index ba7d245..63db8ae 100644
--- a/README.md
+++ b/README.md
@@ -18,5 +18,7 @@
- Sprint: Shift
- Interact (enter/exit car): E
- Flashlight: F
+- Radial command menu: Q
+- Fireball: Shift + B
- Phone: Tab
- Pause menu: Esc
diff --git a/art/character/Ashling Swarmer/ashling-swarmer-reference.png b/art/character/Ashling Swarmer/ashling-swarmer-reference.png
new file mode 100644
index 0000000..79f6f34
Binary files /dev/null and b/art/character/Ashling Swarmer/ashling-swarmer-reference.png differ
diff --git a/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-2.png b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-2.png
new file mode 100644
index 0000000..5790a49
Binary files /dev/null and b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-2.png differ
diff --git a/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-3.png b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-3.png
new file mode 100644
index 0000000..0cad395
Binary files /dev/null and b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-3.png differ
diff --git a/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-4.png b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-4.png
new file mode 100644
index 0000000..8f26a14
Binary files /dev/null and b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-4.png differ
diff --git a/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-5.png b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-5.png
new file mode 100644
index 0000000..6cc77a1
Binary files /dev/null and b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-5.png differ
diff --git a/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-6.png b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-6.png
new file mode 100644
index 0000000..c807659
Binary files /dev/null and b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale-6.png differ
diff --git a/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale.png b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale.png
new file mode 100644
index 0000000..27bfeef
Binary files /dev/null and b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference-grey-scale.png differ
diff --git a/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference.png b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference.png
new file mode 100644
index 0000000..d55ccde
Binary files /dev/null and b/art/character/Ashling Swarmer/ashling-swarmer-t-pose-reference.png differ
diff --git a/art/character/Ember/ember-reference-tpose-only.png b/art/character/Ember/ember-reference-tpose-only.png
new file mode 100644
index 0000000..1009847
Binary files /dev/null and b/art/character/Ember/ember-reference-tpose-only.png differ
diff --git a/art/character/Ember/ember-reference.png b/art/character/Ember/ember-reference.png
new file mode 100644
index 0000000..bbcb972
Binary files /dev/null and b/art/character/Ember/ember-reference.png differ
diff --git a/art/character/Ember/ember-t-pose-ref.png b/art/character/Ember/ember-t-pose-ref.png
new file mode 100644
index 0000000..3642859
Binary files /dev/null and b/art/character/Ember/ember-t-pose-ref.png differ
diff --git a/art/character/Ember/ember.glb b/art/character/Ember/ember.glb
new file mode 100644
index 0000000..95c64b7
--- /dev/null
+++ b/art/character/Ember/ember.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:521924715507df4a7498bff38ea59885e0672b24d34d7ceb9a172b31e162b912
+size 47978484
diff --git a/game/GEMINI.md b/game/GEMINI.md
index 0ac7675..9ee3090 100644
--- a/game/GEMINI.md
+++ b/game/GEMINI.md
@@ -7,15 +7,15 @@ This directory contains the Godot 4.5 game project for "Promiscuity".
- **Engine Version**: Godot 4.5 (Forward Plus).
- **Language**: GDScript is the primary scripting language. C# is enabled but used only when performance or specific library needs arise.
- **Naming Conventions**:
- - Files/Folders: `snake_case.gd`, `snake_case.tscn`.
- - Variables/Functions: `snake_case`.
- - Constants: `SCREAMING_SNAKE_CASE`.
- - Classes: `PascalCase`.
+ - Files/Folders: `snake_case.gd`, `snake_case.tscn`.
+ - Variables/Functions: `snake_case`.
+ - Constants: `SCREAMING_SNAKE_CASE`.
+ - Classes: `PascalCase`.
- **Autoloads**: Use the established singletons for global state and services:
- - `AuthState`: JWT authentication and session.
- - `CharacterService`: API calls for character management.
- - `QuestManager`: Game quest progression.
- - `DialogSystem`: UI dialog and interaction.
+ - `AuthState`: JWT authentication and session.
+ - `CharacterService`: API calls for character management.
+ - `QuestManager`: Game quest progression.
+ - `DialogSystem`: UI dialog and interaction.
- **Node Paths**: Prefer `unique names` (%NodeName) or `@onready` variables for node references. Avoid fragile string paths.
## Asset Pipeline
@@ -27,9 +27,9 @@ This directory contains the Godot 4.5 game project for "Promiscuity".
## Project Organization
- `scenes/`: Scene files and associated scripts.
- - `Characters/`: Player and NPC scenes.
- - `Levels/`: Game world/environments.
- - `UI/`: Menus, HUDs, and UI components.
+ - `Characters/`: Player and NPC scenes.
+ - `Levels/`: Game world/environments.
+ - `UI/`: Menus, HUDs, and UI components.
- `assets/`: Raw assets (textures, models, audio).
- `addons/`: Third-party plugins (e.g., `simplegrasstextured`).
diff --git a/game/addons/godot_mcp/.DS_Store b/game/addons/godot_mcp/.DS_Store
new file mode 100644
index 0000000..ea0ce1d
Binary files /dev/null and b/game/addons/godot_mcp/.DS_Store differ
diff --git a/game/addons/godot_mcp/command_handler.gd b/game/addons/godot_mcp/command_handler.gd
new file mode 100644
index 0000000..c6c1a7e
--- /dev/null
+++ b/game/addons/godot_mcp/command_handler.gd
@@ -0,0 +1,2582 @@
+# Structure for addons/godot_mcp/command_handler.gd
+@tool
+extends RefCounted
+
+var editor_plugin: EditorPlugin = null
+
+# Initialize with reference to the editor plugin
+func set_editor_plugin(plugin):
+ editor_plugin = plugin
+
+# Main command handling function
+func handle_command(command_type, params):
+ match command_type:
+ "GET_SCENE_INFO":
+ return handle_get_scene_info()
+ "OPEN_SCENE":
+ return handle_open_scene(params)
+ "SAVE_SCENE":
+ return handle_save_scene()
+ "CREATE_CHILD_OBJECT":
+ return handle_create_child_object(params)
+ "NEW_SCENE":
+ return handle_new_scene(params)
+ "CREATE_OBJECT":
+ return handle_create_object(params)
+ "DELETE_OBJECT":
+ return handle_delete_object(params)
+ "FIND_OBJECTS_BY_NAME":
+ return handle_find_objects_by_name(params)
+ "GET_OBJECT_PROPERTIES":
+ return handle_get_object_properties(params)
+ "SET_PROPERTY":
+ return handle_set_property(params)
+ "SET_COLLISION_SHAPE":
+ return handle_set_collision_shape(params)
+ "SET_OBJECT_TRANSFORM":
+ return handle_set_object_transform(params)
+ "CREATE_CHILD_OBJECT":
+ return handle_create_child_object(params)
+ "GET_ASSET_LIST":
+ return handle_get_asset_list(params)
+ "VIEW_SCRIPT":
+ return handle_view_script(params)
+ "SET_NESTED_PROPERTY":
+ return handle_set_nested_property(params)
+ "SET_PARENT":
+ return handle_set_parent(params)
+ "CREATE_SCRIPT":
+ return handle_create_script(params)
+ "UPDATE_SCRIPT":
+ return handle_update_script(params)
+ "LIST_SCRIPTS":
+ return handle_list_scripts(params)
+ "DELETE_SCRIPT":
+ return handle_delete_script(params)
+ "DELETE_FILE":
+ return handle_delete_file(params)
+ "EDITOR_CONTROL":
+ return handle_editor_control(params)
+ "SET_MATERIAL":
+ return handle_set_material(params)
+ "IMPORT_ASSET":
+ return handle_import_asset(params)
+ "SET_MESH":
+ return handle_set_mesh(params)
+ "CREATE_PREFAB":
+ return handle_create_packed_scene(params)
+ "INSTANTIATE_PREFAB":
+ return handle_instantiate_prefab(params)
+ "SHOW_MESSAGE":
+ return handle_show_message(params)
+ "REIMPORT_ASSET":
+ return handle_reimport_asset(params)
+ "IMPORT_GLB_SCENE":
+ return handle_import_glb_scene(params)
+ _:
+ return {"error": "Unknown command type: " + command_type}
+
+# Scene commands
+func handle_get_scene_info():
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ var scene_info = {
+ "name": current_scene.name,
+ "path": current_scene.scene_file_path,
+ "hierarchy": _get_hierarchy_recursive(current_scene),
+ "root_objects": []
+ }
+
+ # Get root-level nodes
+ for child in current_scene.get_children():
+ scene_info.root_objects.append({
+ "name": child.name,
+ "type": child.get_class()
+ })
+
+ return scene_info
+
+# Helper function to recursively build hierarchy
+func _get_hierarchy_recursive(node):
+ var hierarchy = {
+ "name": node.name,
+ "type": node.get_class(),
+ "children": []
+ }
+
+ # Add transform info for spatial nodes
+ if node is Node3D:
+ hierarchy["transform"] = {
+ "position": [node.position.x, node.position.y, node.position.z],
+ "rotation": [node.rotation_degrees.x, node.rotation_degrees.y, node.rotation_degrees.z],
+ "scale": [node.scale.x, node.scale.y, node.scale.z]
+ }
+ elif node is Node2D:
+ hierarchy["transform"] = {
+ "position": [node.position.x, node.position.y],
+ "rotation": node.rotation_degrees,
+ "scale": [node.scale.x, node.scale.y]
+ }
+
+ # Add script info if available
+ if node.get_script():
+ hierarchy["script"] = node.get_script().resource_path
+
+ # Add all child nodes recursively
+ for child in node.get_children():
+ hierarchy["children"].append(_get_hierarchy_recursive(child))
+
+ return hierarchy
+
+func handle_open_scene(params):
+ if not params.has("scene_path"):
+ return {"error": "Missing required parameter: scene_path"}
+
+ var scene_path = params.scene_path
+ var editor_interface = editor_plugin.get_editor_interface()
+
+ # Check if file exists
+ if not FileAccess.file_exists(scene_path):
+ return {"error": "Scene file does not exist: " + scene_path}
+
+ # Save current scene if needed
+ if params.get("save_current", false):
+ editor_interface.save_scene()
+
+ # Open the scene
+ editor_interface.open_scene_from_path(scene_path)
+ return {"message": "Scene opened successfully: " + scene_path}
+
+func handle_save_scene():
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ editor_interface.save_scene()
+ return {"message": "Scene saved successfully: " + current_scene.scene_file_path}
+
+func handle_new_scene(params):
+ if not params.has("scene_path"):
+ return {"error": "Missing required parameter: scene_path"}
+
+ var scene_path = params.scene_path
+ var editor_interface = editor_plugin.get_editor_interface()
+
+ # Check if file exists and handle overwrite
+ if FileAccess.file_exists(scene_path) and not params.get("overwrite", false):
+ return {"error": "Scene file already exists. Use overwrite=true to replace it."}
+
+ # Create directory if needed
+ var directory = scene_path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(directory):
+ var error = DirAccess.make_dir_recursive_absolute(directory)
+ if error != OK:
+ return {"error": "Failed to create directory: " + directory}
+
+ # Create a new scene with a Node as root
+ var root_node = Node.new()
+ root_node.name = scene_path.get_file().get_basename()
+
+ # Create a packed scene and save it
+ var packed_scene = PackedScene.new()
+ var result = packed_scene.pack(root_node)
+ if result != OK:
+ return {"error": "Failed to pack scene: " + str(result)}
+
+ var error = ResourceSaver.save(packed_scene, scene_path)
+ if error != OK:
+ return {"error": "Failed to save new scene: " + str(error)}
+
+ # Open the newly created scene
+ editor_interface.open_scene_from_path(scene_path)
+
+ return {"message": "New scene created successfully: " + scene_path}
+
+# Object commands
+func handle_create_object(params):
+ var type = params.get("type", "EMPTY")
+ var name = params.get("name", "")
+ var location = params.get("location", [0, 0, 0])
+ var rotation = params.get("rotation", [0, 0, 0])
+ var scale = params.get("scale", [1, 1, 1])
+ var replace_if_exists = params.get("replace_if_exists", false)
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Check if name already exists and handle replacement
+ if name != "":
+ var existing_node = current_scene.find_child(name, true, false)
+ if existing_node and not replace_if_exists:
+ return {"error": "Node with name '" + name + "' already exists. Use replace_if_exists=true to replace it."}
+ elif existing_node and replace_if_exists:
+ existing_node.queue_free()
+
+ # Create the node based on type (case-insensitive)
+ var node
+ var upper_type = type.to_upper()
+
+ # Handle common 3D node types
+ match upper_type:
+ "NODE", "EMPTY":
+ node = Node3D.new()
+ "NODE3D":
+ node = Node3D.new()
+ "SPATIAL": # For compatibility with Godot 3.x terminology
+ node = Node3D.new()
+ "MESH", "MESHINSTANCE3D":
+ node = MeshInstance3D.new()
+ "CUBE", "BOX":
+ node = MeshInstance3D.new()
+ node.mesh = BoxMesh.new()
+ "SPHERE":
+ node = MeshInstance3D.new()
+ node.mesh = SphereMesh.new()
+ "CYLINDER":
+ node = MeshInstance3D.new()
+ node.mesh = CylinderMesh.new()
+ "PLANE":
+ node = MeshInstance3D.new()
+ node.mesh = PlaneMesh.new()
+ "CAMERA", "CAMERA3D":
+ node = Camera3D.new()
+ "LIGHT", "DIRECTIONALLIGHT", "DIRECTIONALLIGHT3D":
+ node = DirectionalLight3D.new()
+ "SPOTLIGHT", "SPOTLIGHT3D":
+ node = SpotLight3D.new()
+ "OMNILIGHT", "OMNILIGHT3D":
+ node = OmniLight3D.new()
+ "RIGIDBODY", "RIGIDBODY3D":
+ node = RigidBody3D.new()
+ "STATICBODY", "STATICBODY3D":
+ node = StaticBody3D.new()
+ "CHARACTERBODY", "CHARACTERBODY3D":
+ node = CharacterBody3D.new()
+ "AREA", "AREA3D":
+ node = Area3D.new()
+ "COLLISION", "COLLISIONSHAPE3D":
+ node = CollisionShape3D.new()
+ # Add a default sphere shape
+ node.shape = SphereShape3D.new()
+
+ # Handle common 2D node types
+ "NODE2D":
+ node = Node2D.new()
+ "SPRITE", "SPRITE2D":
+ node = Sprite2D.new()
+ "CAMERA2D":
+ node = Camera2D.new()
+ "AREA2D":
+ node = Area2D.new()
+ "COLLISION2D", "COLLISIONSHAPE2D":
+ node = CollisionShape2D.new()
+ # Add a default circle shape
+ node.shape = CircleShape2D.new()
+ "RIGIDBODY2D":
+ node = RigidBody2D.new()
+ "STATICBODY2D":
+ node = StaticBody2D.new()
+ "CHARACTERBODY2D":
+ node = CharacterBody2D.new()
+
+ # Handle UI node types
+ "CONTROL":
+ node = Control.new()
+ "PANEL":
+ node = Panel.new()
+ "BUTTON":
+ node = Button.new()
+ "LABEL":
+ node = Label.new()
+ "LINEEDIT":
+ node = LineEdit.new()
+ "TEXTEDIT":
+ node = TextEdit.new()
+ "CONTAINER":
+ node = Container.new()
+ "VBOX", "VBOXCONTAINER":
+ node = VBoxContainer.new()
+ "HBOX", "HBOXCONTAINER":
+ node = HBoxContainer.new()
+ _:
+ # Try to create the node directly by class name using ClassDB
+ if ClassDB.class_exists(type) and ClassDB.can_instantiate(type):
+ node = ClassDB.instantiate(type)
+ else:
+ return {"error": "Unsupported object type: " + type}
+
+ # Set node name if provided
+ if name != "":
+ node.name = name
+
+ # Add to scene
+ current_scene.add_child(node)
+ node.owner = current_scene
+
+ # Set transform for Node3D or Node2D objects
+ if node is Node3D:
+ node.position = Vector3(location[0], location[1], location[2])
+ node.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2])
+ node.scale = Vector3(scale[0], scale[1], scale[2])
+ elif node is Node2D:
+ node.position = Vector2(location[0], location[1])
+ node.rotation_degrees = rotation[0]
+ node.scale = Vector2(scale[0], scale[1])
+
+ return {
+ "name": node.name,
+ "type": node.get_class(),
+ "path": current_scene.get_path_to(node)
+ }
+
+
+func handle_delete_object(params):
+ if not params.has("name"):
+ return {"error": "Missing required parameter: name"}
+
+ var name_or_path = params.name
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find node by name or path
+ var node = null
+
+ # Check if it's a path (contains /)
+ if "/" in name_or_path:
+ # Try direct path lookup first
+ node = current_scene.get_node_or_null(name_or_path)
+
+ # If that fails, try with leading slash
+ if not node and not name_or_path.begins_with("/"):
+ node = current_scene.get_node_or_null("/" + name_or_path)
+
+ # If still not found, try parsing the path components
+ if not node:
+ var parts = name_or_path.split("/")
+ var current = current_scene
+
+ for part in parts:
+ if part == "":
+ continue
+
+ var found = false
+ for child in current.get_children():
+ if child.name == part:
+ current = child
+ found = true
+ break
+
+ if not found:
+ # Try searching recursively for this part
+ var found_node = current.find_child(part, true, false)
+ if found_node:
+ current = found_node
+ else:
+ # Part not found, path is invalid
+ return {"error": "Could not find part '" + part + "' in path: " + name_or_path}
+
+ node = current
+ else:
+ # Simple name lookup for non-path names
+ node = current_scene.find_child(name_or_path, true, false)
+
+ if not node:
+ return {"error": "Node not found: " + name_or_path}
+
+ # Store the node's path for the response before deleting
+ var node_path = current_scene.get_path_to(node)
+ var node_type = node.get_class()
+
+ # Delete the node
+ node.queue_free()
+
+ return {
+ "message": "Node deleted: " + name_or_path,
+ "path": node_path,
+ "type": node_type
+ }
+#func handle_delete_object(params):
+ #if not params.has("name"):
+ #return {"error": "Missing required parameter: name"}
+ #
+ #var name = params.name
+ #var editor_interface = editor_plugin.get_editor_interface()
+ #var current_scene = editor_interface.get_edited_scene_root()
+ #
+ #if current_scene == null:
+ #return {"error": "No scene is currently open"}
+ #
+ ## Find node by name
+ #var node = current_scene.find_child(name, true, false)
+ #if not node:
+ #return {"error": "Node not found: " + name}
+ #
+ ## Delete the node
+ #node.queue_free()
+ #return {"message": "Node deleted: " + name}
+
+func handle_find_objects_by_name(params):
+ if not params.has("name"):
+ return {"error": "Missing required parameter: name"}
+
+ var search_name = params.name
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ var objects = []
+ var nodes = _find_nodes_by_name(current_scene, search_name)
+
+ for node in nodes:
+ objects.append({
+ "name": node.name,
+ "path": _get_node_path(node),
+ "type": node.get_class()
+ })
+
+ return {"objects": objects}
+
+# Helper function to find nodes recursively
+func _find_nodes_by_name(root, search_name):
+ var result = []
+
+ if search_name in root.name:
+ result.append(root)
+
+ for child in root.get_children():
+ result.append_array(_find_nodes_by_name(child, search_name))
+
+ return result
+
+# Helper function to get a node's path relative to the scene root
+func _get_node_path(node):
+ var current_scene = editor_plugin.get_editor_interface().get_edited_scene_root()
+ if current_scene:
+ return current_scene.get_path_to(node)
+ return node.get_path()
+
+func handle_get_object_properties(params):
+ if not params.has("name"):
+ return {"error": "Missing required parameter: name"}
+
+ var name_or_path = params.name
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ var node
+
+ # Check if we're dealing with a path or just a name
+ if "/" in name_or_path:
+ # It's a path, try to get the node using get_node
+ node = current_scene.get_node_or_null(name_or_path)
+
+ # If that fails, try with NodePath
+ if not node:
+ var node_path = NodePath(name_or_path)
+ node = current_scene.get_node_or_null(node_path)
+
+ # Try finding from root with a leading slash
+ if not node and not name_or_path.begins_with("/"):
+ node = current_scene.get_node_or_null("/" + name_or_path)
+ else:
+ # It's just a name, use find_child (which searches recursively)
+ node = current_scene.find_child(name_or_path, true, false)
+
+ if not node:
+ return {"error": "Node not found: " + name_or_path}
+
+ # Get properties
+ var properties = {
+ "name": node.name,
+ "type": node.get_class(),
+ "path": current_scene.get_path_to(node),
+ "visible": node.visible if "visible" in node else true,
+ "components": []
+ }
+
+ # Handle transform properties for 3D nodes
+ if node is Node3D:
+ properties["transform"] = {
+ "position": [node.position.x, node.position.y, node.position.z],
+ "rotation": [node.rotation_degrees.x, node.rotation_degrees.y, node.rotation_degrees.z],
+ "scale": [node.scale.x, node.scale.y, node.scale.z]
+ }
+ elif node is Node2D:
+ properties["transform"] = {
+ "position": [node.position.x, node.position.y],
+ "rotation": node.rotation_degrees,
+ "scale": [node.scale.x, node.scale.y]
+ }
+
+ # Get node properties and components
+ if node.get_script():
+ properties["components"].append({
+ "type": "Script",
+ "path": node.get_script().resource_path
+ })
+
+ # Get children information
+ properties["children"] = []
+ for child in node.get_children():
+ properties["children"].append({
+ "name": child.name,
+ "type": child.get_class(),
+ "path": current_scene.get_path_to(child)
+ })
+
+ # Get parent information
+ var parent = node.get_parent()
+ if parent and parent != current_scene:
+ properties["parent"] = {
+ "name": parent.name,
+ "type": parent.get_class(),
+ "path": current_scene.get_path_to(parent)
+ }
+
+ return properties
+
+func handle_set_object_transform(params):
+ if not params.has("name"):
+ return {"error": "Missing required parameter: name"}
+
+ var name = params.name
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find node by name
+ var node = current_scene.find_child(name, true, false)
+ if not node:
+ return {"error": "Node not found: " + name}
+
+ # Check if node is 3D
+ if not node is Node3D:
+ return {"error": "Node is not a 3D node: " + name}
+
+ # Set transform properties
+ if params.has("location"):
+ var loc = params.location
+ node.position = Vector3(loc[0], loc[1], loc[2])
+
+ if params.has("rotation"):
+ var rot = params.rotation
+ node.rotation_degrees = Vector3(rot[0], rot[1], rot[2])
+
+ if params.has("scale"):
+ var scale = params.scale
+ node.scale = Vector3(scale[0], scale[1], scale[2])
+
+ return {"message": "Transform updated for node: " + name}
+
+# Asset commands
+func handle_get_asset_list(params):
+ var type = params.get("type", "")
+ var search_pattern = params.get("search_pattern", "*")
+ var folder = params.get("folder", "res://")
+
+ var assets = []
+ var dir = DirAccess.open(folder)
+
+ if dir == null:
+ return {"error": "Unable to access directory: " + folder}
+
+ dir.list_dir_begin()
+ var file_name = dir.get_next()
+
+ while file_name != "":
+ var full_path = folder.path_join(file_name)
+
+ if dir.current_is_dir():
+ # Handle directories if needed
+ pass
+ else:
+ # Check if it matches the search pattern
+ if search_pattern == "*" or search_pattern in file_name:
+ # Check type if specified
+ var add_file = true
+ if type != "":
+ # Determine file type based on extension
+ var extension = file_name.get_extension().to_lower()
+ match type.to_lower():
+ "scene":
+ add_file = extension == "tscn" or extension == "scn"
+ "script":
+ add_file = extension == "gd" or extension == "cs"
+ "texture":
+ add_file = extension in ["png", "jpg", "jpeg", "webp"]
+ "material":
+ add_file = extension == "material" or extension == "tres"
+ "prefab", "packedscene":
+ add_file = extension == "tscn" or extension == "scn"
+
+ if add_file:
+ assets.append({
+ "name": file_name,
+ "path": full_path,
+ "type": _get_resource_type(full_path)
+ })
+
+ file_name = dir.get_next()
+
+ dir.list_dir_end()
+ return {"assets": assets}
+
+# Helper function to determine resource type
+func _get_resource_type(path):
+ var extension = path.get_extension().to_lower()
+ match extension:
+ "tscn", "scn":
+ return "PackedScene"
+ "gd":
+ return "GDScript"
+ "cs":
+ return "CSharpScript"
+ "png", "jpg", "jpeg", "webp":
+ return "Texture"
+ "material", "tres":
+ return "Material"
+ "wav", "mp3", "ogg":
+ return "AudioStream"
+ _:
+ return "Resource"
+
+# Script commands
+func handle_view_script(params):
+ if not params.has("script_path"):
+ return {"error": "Missing required parameter: script_path"}
+
+ var script_path = params.script_path
+ var require_exists = params.get("require_exists", true)
+
+ if not FileAccess.file_exists(script_path):
+ if require_exists:
+ return {"error": "Script file does not exist: " + script_path}
+ else:
+ return {"exists": false, "message": "Script file does not exist: " + script_path}
+
+ var file = FileAccess.open(script_path, FileAccess.READ)
+ if file == null:
+ return {"error": "Failed to open script file: " + script_path}
+
+ var content = file.get_as_text()
+ return {"exists": true, "content": content}
+
+func handle_create_script(params):
+ if not params.has("script_name"):
+ return {"error": "Missing required parameter: script_name"}
+
+ var script_name = params.script_name
+ var script_type = params.get("script_type", "Node")
+ var nam = params.get("namespace", "")
+ var script_folder = params.get("script_folder", "res://scripts")
+ var overwrite = params.get("overwrite", false)
+ var content = params.get("content", "")
+
+ # Ensure script has .gd extension
+ if not script_name.ends_with(".gd"):
+ script_name += ".gd"
+
+ # Create full script path
+ var script_path = script_folder.path_join(script_name)
+
+ # Check if directory exists, create if needed
+ if not DirAccess.dir_exists_absolute(script_folder):
+ var error = DirAccess.make_dir_recursive_absolute(script_folder)
+ if error != OK:
+ return {"error": "Failed to create script directory: " + script_folder}
+
+ # Check if script already exists
+ if FileAccess.file_exists(script_path) and not overwrite:
+ return {"error": "Script already exists. Use overwrite=true to replace it."}
+
+ # Create script content if not provided
+ if content == "":
+ content = _generate_script_template(script_type, nam)
+
+ # Write script file
+ var file = FileAccess.open(script_path, FileAccess.WRITE)
+ if file == null:
+ return {"error": "Failed to create script file: " + script_path}
+
+ file.store_string(content)
+
+ return {"message": "Script created successfully: " + script_path}
+
+# Helper function to generate script template
+func _generate_script_template(node_type, nam):
+ var template = ""
+
+ # Add class_name if namespace is provided
+ if nam != "":
+ template += "class_name " + nam + "\n\n"
+
+ # Add extends
+ template += "extends " + node_type + "\n\n"
+
+ # Add basic structure
+ template += "# Properties\n\n"
+ template += "# Called when the node enters the scene tree\n"
+ template += "func _ready():\n"
+ template += "\tpass\n\n"
+ template += "# Called every frame\n"
+ template += "func _process(delta):\n"
+ template += "\tpass\n"
+
+ return template
+
+func handle_update_script(params):
+ if not params.has("script_path") or not params.has("content"):
+ return {"error": "Missing required parameters: script_path and content"}
+
+ var script_path = params.script_path
+ var content = params.content
+ var create_if_missing = params.get("create_if_missing", false)
+ var create_folder_if_missing = params.get("create_folder_if_missing", false)
+
+ # Check if script exists
+ if not FileAccess.file_exists(script_path):
+ if not create_if_missing:
+ return {"error": "Script file does not exist: " + script_path}
+
+ # Create directory if needed
+ var directory = script_path.get_base_dir()
+ if create_folder_if_missing and not DirAccess.dir_exists_absolute(directory):
+ var dir_error = DirAccess.make_dir_recursive_absolute(directory)
+ if dir_error != OK:
+ return {"error": "Failed to create directory: " + directory}
+
+ # Write script content
+ var file = FileAccess.open(script_path, FileAccess.WRITE)
+ if file == null:
+ return {"error": "Failed to open script file for writing: " + script_path}
+
+ file.store_string(content)
+
+ return {"message": "Script updated successfully: " + script_path}
+
+func handle_list_scripts(params):
+ var folder_path = params.get("folder_path", "res://")
+
+ var scripts = []
+ var dir = DirAccess.open(folder_path)
+
+ if dir == null:
+ return {"error": "Unable to access directory: " + folder_path}
+
+ dir.list_dir_begin()
+ var file_name = dir.get_next()
+
+ while file_name != "":
+ if not dir.current_is_dir():
+ var extension = file_name.get_extension().to_lower()
+ if extension == "gd" or extension == "cs":
+ scripts.append(folder_path.path_join(file_name))
+
+ file_name = dir.get_next()
+
+ dir.list_dir_end()
+ return {"scripts": scripts}
+
+# Editor control commands
+func handle_editor_control(params):
+ if not params.has("command"):
+ return {"error": "Missing required parameter: command"}
+
+ var command = params.command
+ var editor_interface = editor_plugin.get_editor_interface()
+
+ match command:
+ "PLAY":
+ editor_interface.play_main_scene()
+ return {"message": "Started playing the main scene"}
+ "STOP":
+ editor_interface.stop_playing_scene()
+ return {"message": "Stopped playing the scene"}
+ "SAVE":
+ editor_interface.save_scene()
+ return {"message": "Scene saved"}
+ "READ_CONSOLE":
+ # Godot doesn't have a direct API for reading console output
+ return {"message": "Console reading not implemented in Godot"}
+ _:
+ return {"error": "Unknown editor command: " + command}
+
+# Material commands
+func handle_set_material(params):
+ if not params.has("object_name"):
+ return {"error": "Missing required parameter: object_name"}
+
+ var object_name = params.object_name
+ var material_name = params.get("material_name", "")
+ var color = params.get("color", [1.0, 1.0, 1.0, 1.0])
+ var create_if_missing = params.get("create_if_missing", true)
+
+ print("handle_set_material called for node: ", object_name, " color: ", color)
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find node using the improved node lookup
+ var node = _find_node(current_scene, object_name)
+ if not node:
+ return {"error": "Node not found: " + object_name}
+
+ print("Node found: ", node.name, " type: ", node.get_class())
+
+ # Check if node can have materials
+ if not (node is MeshInstance3D or node is CSGShape3D):
+ return {"error": "Node does not support materials: " + object_name}
+
+ var material
+
+ # Create or load material
+ if material_name != "":
+ var material_path = "res://materials/" + material_name + ".material"
+
+ if FileAccess.file_exists(material_path):
+ # Load existing material
+ material = load(material_path)
+ elif create_if_missing:
+ # Create directory if needed
+ if not DirAccess.dir_exists_absolute("res://materials"):
+ DirAccess.make_dir_recursive_absolute("res://materials")
+
+ # Create new material
+ material = StandardMaterial3D.new()
+
+ # Set color
+ if color.size() >= 3:
+ var albedo_color = Color(color[0], color[1], color[2])
+ if color.size() >= 4:
+ albedo_color.a = color[3]
+ material.albedo_color = albedo_color
+
+ # Save material
+ var error = ResourceSaver.save(material, material_path)
+ if error != OK:
+ return {"error": "Failed to save material: " + str(error)}
+ else:
+ return {"error": "Material not found and create_if_missing is false"}
+ else:
+ # Create instance material
+ material = StandardMaterial3D.new()
+
+ # Set color
+ if color.size() >= 3:
+ var albedo_color = Color(color[0], color[1], color[2])
+ if color.size() >= 4:
+ albedo_color.a = color[3]
+ material.albedo_color = albedo_color
+
+ # Apply material
+ if node is MeshInstance3D:
+ node.material_override = material
+ elif node is CSGShape3D:
+ node.material = material
+
+ if material_name != "":
+ return {
+ "material_name": material_name,
+ "path": "res://materials/" + material_name + ".material",
+ "message": "Applied shared material to " + object_name
+ }
+ else:
+ return {
+ "material_name": "instance_material",
+ "message": "Applied instance material to " + object_name
+ }
+
+# Asset import
+func handle_import_asset(params):
+ if not params.has("source_path") or not params.has("target_path"):
+ return {"error": "Missing required parameters: source_path and target_path"}
+
+ var source_path = params.source_path
+ var target_path = params.target_path
+ var overwrite = params.get("overwrite", false)
+
+ # Ensure target_path starts with res://
+ if not target_path.begins_with("res://"):
+ target_path = "res://" + target_path
+
+ # Check if source file exists
+ if not FileAccess.file_exists(source_path):
+ return {"error": "Source file does not exist: " + source_path}
+
+ # Check if target file exists
+ if FileAccess.file_exists(target_path) and not overwrite:
+ return {"error": "Target file already exists. Use overwrite=true to replace it."}
+
+ # Create target directory if needed
+ var target_dir = target_path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(target_dir):
+ var dir_error = DirAccess.make_dir_recursive_absolute(target_dir)
+ if dir_error != OK:
+ return {"error": "Failed to create target directory: " + target_dir}
+
+ # Copy the file
+ var source_file = FileAccess.open(source_path, FileAccess.READ)
+ if source_file == null:
+ return {"error": "Failed to open source file: " + source_path}
+
+ var target_file = FileAccess.open(target_path, FileAccess.WRITE)
+ if target_file == null:
+ return {"error": "Failed to create target file: " + target_path}
+
+ target_file.store_buffer(source_file.get_buffer(source_file.get_length()))
+
+ return {
+ "success": true,
+ "message": "Asset imported successfully: " + target_path
+ }
+
+func handle_create_packed_scene(params):
+ """Create a packed scene (prefab) from an existing node."""
+ if not params.has("object_name") or not params.has("prefab_path"):
+ return {"error": "Missing required parameters: object_name and prefab_path"}
+
+ var object_name = params.object_name
+ var prefab_path = params.prefab_path
+ var overwrite = params.get("overwrite", false)
+
+ # Ensure prefab_path starts with res://
+ if not prefab_path.begins_with("res://"):
+ prefab_path = "res://" + prefab_path
+
+ # Ensure it has .tscn extension
+ if not prefab_path.ends_with(".tscn"):
+ prefab_path += ".tscn"
+
+ # Check if file exists and handle overwrite
+ if FileAccess.file_exists(prefab_path) and not overwrite:
+ return {"error": "Packed scene file already exists. Use overwrite=true to replace it."}
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find node by name
+ var node = current_scene.find_child(object_name, true, false)
+ if not node:
+ return {"error": "Node not found: " + object_name}
+
+ # Create directory if needed
+ var directory = prefab_path.get_base_dir()
+ if not DirAccess.dir_exists_absolute(directory):
+ var dir_error = DirAccess.make_dir_recursive_absolute(directory)
+ if dir_error != OK:
+ return {"error": "Failed to create directory: " + directory}
+
+ # Create packed scene
+ var packed_scene = PackedScene.new()
+ var result = packed_scene.pack(node)
+ if result != OK:
+ return {"error": "Failed to pack scene: " + str(result)}
+
+ # Save packed scene
+ result = ResourceSaver.save(packed_scene, prefab_path)
+ if result != OK:
+ return {"error": "Failed to save packed scene: " + str(result)}
+
+ return {
+ "success": true,
+ "path": prefab_path,
+ "message": "Packed scene created successfully from " + object_name
+ }
+
+func handle_instantiate_prefab(params):
+ """Instantiate a packed scene (prefab) into the current scene."""
+ if not params.has("prefab_path"):
+ return {"error": "Missing required parameter: prefab_path"}
+
+ var prefab_path = params.prefab_path
+ var position_x = params.get("position_x", 0.0)
+ var position_y = params.get("position_y", 0.0)
+ var position_z = params.get("position_z", 0.0)
+ var rotation_x = params.get("rotation_x", 0.0)
+ var rotation_y = params.get("rotation_y", 0.0)
+ var rotation_z = params.get("rotation_z", 0.0)
+
+ # Ensure prefab_path starts with res://
+ if not prefab_path.begins_with("res://"):
+ prefab_path = "res://" + prefab_path
+
+ # Check if file exists
+ if not FileAccess.file_exists(prefab_path):
+ return {"error": "Packed scene file does not exist: " + prefab_path}
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Load the packed scene
+ var scene_resource = load(prefab_path)
+ if not scene_resource is PackedScene:
+ return {"error": "Failed to load packed scene: " + prefab_path}
+
+ # Instantiate the scene
+ var instance = scene_resource.instantiate()
+ if not instance:
+ return {"error": "Failed to instantiate packed scene"}
+
+ # Add to the current scene
+ current_scene.add_child(instance)
+ instance.owner = current_scene
+
+ # Set transform if it's a spatial node
+ if instance is Node3D:
+ instance.position = Vector3(position_x, position_y, position_z)
+ instance.rotation_degrees = Vector3(rotation_x, rotation_y, rotation_z)
+
+ return {
+ "success": true,
+ "instance_name": instance.name,
+ "message": "Packed scene instantiated successfully"
+ }
+func handle_set_property(params):
+ if not params.has("node_name") or not params.has("property_name") or not params.has("value"):
+ return {"error": "Missing required parameters: node_name, property_name, and value"}
+
+ var node_name = params.node_name
+ var property_name = params.property_name
+ var value = params.value
+ var force_type = params.get("force_type", "")
+
+ print("handle_set_property called with node_name: ", node_name, " property_name: ", property_name, " value: ", value)
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find node by name or path
+ var node = null
+
+ # Check if it's a path (contains /)
+ if "/" in node_name:
+ # Try direct path lookup first
+ node = current_scene.get_node_or_null(node_name)
+ if not node:
+ # Try as relative path
+ if not node_name.begins_with("/"):
+ node = current_scene.get_node_or_null("/" + node_name)
+ else:
+ # Simple name lookup
+ node = current_scene.find_child(node_name, true, false)
+
+ if node:
+ print("Node found: ", node.name, " of type: ", node.get_class())
+ else:
+ print("Node not found: ", node_name)
+ return {"error": "Node not found: " + node_name}
+
+ # Handle special properties
+ if property_name == "script":
+ # Check if script file exists
+ if not FileAccess.file_exists(value):
+ return {"error": "Script file not found: " + value}
+
+ # Try to load the script
+ var script_resource = load(value)
+ if not script_resource:
+ return {"error": "Failed to load script: " + value}
+
+ if not script_resource is GDScript:
+ return {"error": "Resource is not a GDScript: " + value}
+
+ # Set the script
+ node.set_script(script_resource)
+ return {"message": "Script set on node '" + node_name + "': " + value}
+
+ # Handle other special properties
+ if property_name == "visible":
+ node.visible = bool(value)
+ return {"message": "Property set: visible = " + str(bool(value))}
+
+ if property_name == "name":
+ # Check if name is already taken
+ var existing = current_scene.find_child(str(value), true, false)
+ if existing != null and existing != node:
+ return {"error": "Name already in use by another node"}
+ node.name = str(value)
+ return {"message": "Node renamed to: " + str(value)}
+
+ # Handle property paths (e.g., "position:x")
+ var parts = property_name.split(":")
+ if parts.size() == 2:
+ var base_property = parts[0]
+ var sub_property = parts[1]
+
+ if base_property in node:
+ var base_value = node.get(base_property)
+
+ # Handle different vector types
+ if base_value is Vector2 or base_value is Vector3 or base_value is Color:
+ if sub_property in ["x", "y", "z", "r", "g", "b", "a"]:
+ # Safety check for Vector2 which doesn't have z
+ if base_value is Vector2 and sub_property == "z":
+ return {"error": "Vector2 doesn't have a z component"}
+
+ # Safety check for components that don't exist
+ if sub_property == "z" and not (base_value is Vector3):
+ return {"error": "Property doesn't have a z component"}
+
+ if sub_property in ["r", "g", "b", "a"] and not (base_value is Color):
+ return {"error": "Only Color has r, g, b, a components"}
+
+ # Convert value to float for vector components
+ var float_value
+ if typeof(value) == TYPE_STRING:
+ float_value = float(value)
+ else:
+ float_value = float(value)
+
+ # Set the appropriate component
+ match sub_property:
+ "x":
+ base_value.x = float_value
+ "y":
+ base_value.y = float_value
+ "z":
+ base_value.z = float_value
+ "r":
+ base_value.r = float_value
+ "g":
+ base_value.g = float_value
+ "b":
+ base_value.b = float_value
+ "a":
+ base_value.a = float_value
+
+ # Set the modified vector back to the node
+ node.set(base_property, base_value)
+ return {"message": "Property set: " + property_name + " = " + str(float_value)}
+
+ return {"error": "Unable to set sub-property: " + property_name}
+
+ # Handle regular properties
+ if not property_name in node:
+ return {"error": "Property not found on node: " + property_name}
+
+ # Get the current property value to determine its type
+ var current_value = node.get(property_name)
+ print("Current value type: ", typeof(current_value))
+
+ var converted_value = value
+
+ # If force_type is specified, use that for conversion
+ if force_type != "":
+ match force_type:
+ "bool":
+ if typeof(value) == TYPE_STRING:
+ converted_value = (value.to_lower() == "true")
+ else:
+ converted_value = bool(value)
+ "int":
+ converted_value = int(value)
+ "float":
+ converted_value = float(value)
+ "string":
+ converted_value = str(value)
+ "Vector2":
+ if typeof(value) == TYPE_ARRAY and value.size() >= 2:
+ converted_value = Vector2(float(value[0]), float(value[1]))
+ "Vector3":
+ if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ converted_value = Vector3(float(value[0]), float(value[1]), float(value[2]))
+ "Color":
+ if typeof(value) == TYPE_ARRAY:
+ if value.size() >= 4:
+ converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3]))
+ elif value.size() >= 3:
+ converted_value = Color(float(value[0]), float(value[1]), float(value[2]))
+ else:
+ # Try to convert the value to match the current property type
+ match typeof(current_value):
+ TYPE_BOOL:
+ if typeof(value) == TYPE_STRING:
+ converted_value = (value.to_lower() == "true")
+ else:
+ converted_value = bool(value)
+ TYPE_INT:
+ converted_value = int(value)
+ TYPE_FLOAT:
+ converted_value = float(value)
+ TYPE_STRING:
+ converted_value = str(value)
+ TYPE_VECTOR2:
+ if typeof(value) == TYPE_ARRAY and value.size() >= 2:
+ converted_value = Vector2(float(value[0]), float(value[1]))
+ TYPE_VECTOR3:
+ if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ converted_value = Vector3(float(value[0]), float(value[1]), float(value[2]))
+ TYPE_COLOR:
+ if typeof(value) == TYPE_ARRAY:
+ if value.size() >= 4:
+ converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3]))
+ elif value.size() >= 3:
+ converted_value = Color(float(value[0]), float(value[1]), float(value[2]))
+
+ print("Converted value: ", converted_value, " of type: ", typeof(converted_value))
+
+ # Try to set the property
+ var previous_value = node.get(property_name)
+
+ # Use set to safely call the setter
+ var error = null
+ node.set(property_name, converted_value)
+
+ # Check if the property actually changed
+ var new_value = node.get(property_name)
+ # Handle type-safe comparison - don't compare different types directly
+ var values_different = false
+ if typeof(new_value) == typeof(converted_value):
+ values_different = (new_value != converted_value)
+ else:
+ # Different types means they're different values
+ values_different = true
+
+ if new_value == previous_value and values_different:
+ return {"warning": "Property might be read-only or conversion failed: " + property_name, "previous_value": previous_value, "attempted_value": converted_value}
+
+ return {"message": "Property set: " + property_name + " = " + str(converted_value)}
+
+
+#func handle_set_property(params):
+ #if not params.has("node_name") or not params.has("property_name") or not params.has("value"):
+ #return {"error": "Missing required parameters: node_name, property_name, and value"}
+ #
+ #var node_name = params.node_name
+ #var property_name = params.property_name
+ #var value = params.value
+ #var force_type = params.get("force_type", "")
+ #
+ #var editor_interface = editor_plugin.get_editor_interface()
+ #var current_scene = editor_interface.get_edited_scene_root()
+ #
+ #if current_scene == null:
+ #return {"error": "No scene is currently open"}
+ #
+ ## Find node by name
+ #var node = current_scene.find_child(node_name, true, false)
+ #if not node:
+ #return {"error": "Node not found: " + node_name}
+ #
+ ## Handle special properties
+ #if property_name == "script":
+ ## Check if script file exists
+ #if not FileAccess.file_exists(value):
+ #return {"error": "Script file not found: " + value}
+ #
+ ## Try to load the script
+ #var script_resource = load(value)
+ #if not script_resource:
+ #return {"error": "Failed to load script: " + value}
+ #
+ #if not script_resource is GDScript:
+ #return {"error": "Resource is not a GDScript: " + value}
+ #
+ ## Set the script
+ #node.set_script(script_resource)
+ #return {"message": "Script set on node '" + node_name + "': " + value}
+ #
+ ## Handle other special properties
+ #if property_name == "visible":
+ #node.visible = bool(value)
+ #return {"message": "Property set: visible = " + str(bool(value))}
+ #
+ #if property_name == "name":
+ ## Check if name is already taken
+ #var existing = current_scene.find_child(str(value), true, false)
+ #if existing != null and existing != node:
+ #return {"error": "Name already in use by another node"}
+ #node.name = str(value)
+ #return {"message": "Node renamed to: " + str(value)}
+ #
+ ## Handle property paths (e.g., "position:x")
+ #var parts = property_name.split(":")
+ #if parts.size() == 2:
+ #var base_property = parts[0]
+ #var sub_property = parts[1]
+ #
+ #if base_property in node:
+ #var base_value = node.get(base_property)
+ #
+ ## Handle different vector types
+ #if base_value is Vector2 or base_value is Vector3 or base_value is Color:
+ #if sub_property in ["x", "y", "z", "r", "g", "b", "a"]:
+ ## Safety check for Vector2 which doesn't have z
+ #if base_value is Vector2 and sub_property == "z":
+ #return {"error": "Vector2 doesn't have a z component"}
+ #
+ ## Safety check for components that don't exist
+ #if sub_property == "z" and not (base_value is Vector3):
+ #return {"error": "Property doesn't have a z component"}
+ #
+ #if sub_property in ["r", "g", "b", "a"] and not (base_value is Color):
+ #return {"error": "Only Color has r, g, b, a components"}
+ #
+ ## Convert value to float for vector components
+ #var float_value
+ #if typeof(value) == TYPE_STRING:
+ #float_value = float(value)
+ #else:
+ #float_value = float(value)
+ #
+ ## Set the appropriate component
+ #match sub_property:
+ #"x": base_value.x = float_value
+ #"y": base_value.y = float_value
+ #"z": base_value.z = float_value
+ #"r": base_value.r = float_value
+ #"g": base_value.g = float_value
+ #"b": base_value.b = float_value
+ #"a": base_value.a = float_value
+ #
+ ## Set the modified vector back to the node
+ #node.set(base_property, base_value)
+ #return {"message": "Property set: " + property_name + " = " + str(float_value)}
+ #
+ #return {"error": "Unable to set sub-property: " + property_name}
+ #
+ ## Handle regular properties
+ #if not property_name in node:
+ #return {"error": "Property not found on node: " + property_name}
+ #
+ ## Get the current property value to determine its type
+ #var current_value = node.get(property_name)
+ #var converted_value = value
+ #
+ ## If force_type is specified, use that for conversion
+ #if force_type != "":
+ #match force_type:
+ #"bool":
+ #if typeof(value) == TYPE_STRING:
+ #converted_value = (value.to_lower() == "true")
+ #else:
+ #converted_value = bool(value)
+ #"int":
+ #converted_value = int(value)
+ #"float":
+ #converted_value = float(value)
+ #"string":
+ #converted_value = str(value)
+ #"Vector2":
+ #if typeof(value) == TYPE_ARRAY and value.size() >= 2:
+ #converted_value = Vector2(float(value[0]), float(value[1]))
+ #"Vector3":
+ #if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ #converted_value = Vector3(float(value[0]), float(value[1]), float(value[2]))
+ #"Color":
+ #if typeof(value) == TYPE_ARRAY:
+ #if value.size() >= 4:
+ #converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3]))
+ #elif value.size() >= 3:
+ #converted_value = Color(float(value[0]), float(value[1]), float(value[2]))
+ #else:
+ ## Try to convert the value to match the current property type
+ #match typeof(current_value):
+ #TYPE_BOOL:
+ #if typeof(value) == TYPE_STRING:
+ #converted_value = (value.to_lower() == "true")
+ #else:
+ #converted_value = bool(value)
+ #TYPE_INT:
+ #converted_value = int(value)
+ #TYPE_FLOAT:
+ #converted_value = float(value)
+ #TYPE_STRING:
+ #converted_value = str(value)
+ #TYPE_VECTOR2:
+ #if typeof(value) == TYPE_ARRAY and value.size() >= 2:
+ #converted_value = Vector2(float(value[0]), float(value[1]))
+ #TYPE_VECTOR3:
+ #if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ #converted_value = Vector3(float(value[0]), float(value[1]), float(value[2]))
+ #TYPE_COLOR:
+ #if typeof(value) == TYPE_ARRAY:
+ #if value.size() >= 4:
+ #converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3]))
+ #elif value.size() >= 3:
+ #converted_value = Color(float(value[0]), float(value[1]), float(value[2]))
+ #
+ ## Try to set the property
+ #var previous_value = node.get(property_name)
+ #
+ ## Use callv to safely call the setter
+ #node.set(property_name, converted_value)
+ #
+ ## Check if the property actually changed
+ #var new_value = node.get(property_name)
+ #if new_value == previous_value and new_value != converted_value:
+ #return {"warning": "Property might be read-only or conversion failed: " + property_name, "previous_value": previous_value, "attempted_value": converted_value}
+ #
+ #return {"message": "Property set: " + property_name + " = " + str(converted_value)}
+
+func handle_set_parent(params):
+ if not params.has("child_name") or not params.has("parent_name"):
+ return {"error": "Missing required parameters: child_name and parent_name"}
+
+ var child_name = params.child_name
+ var parent_name = params.parent_name
+ var keep_global_transform = params.get("keep_global_transform", true)
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find child node (search the entire scene)
+ var child_node = current_scene.find_child(child_name, true, false)
+ if not child_node:
+ return {"error": "Child node not found: " + child_name}
+
+ # Find parent node (search the entire scene)
+ var parent_node
+ if parent_name == "root":
+ parent_node = current_scene
+ else:
+ parent_node = current_scene.find_child(parent_name, true, false)
+
+ if not parent_node:
+ return {"error": "Parent node not found: " + parent_name}
+
+ # Store the global transform if needed
+ var global_transform = null
+ if child_node is Node3D and keep_global_transform:
+ global_transform = child_node.global_transform
+ elif child_node is Node2D and keep_global_transform:
+ global_transform = child_node.global_transform
+
+ # Get the original parent
+ var original_parent = child_node.get_parent()
+ if original_parent:
+ # Remove from original parent
+ original_parent.remove_child(child_node)
+
+ # Add to new parent
+ parent_node.add_child(child_node)
+
+ # Ensure ownership is set correctly
+ child_node.owner = current_scene
+
+ # Set all children's owner recursively
+ _set_owner_recursive(child_node, current_scene)
+
+ # Restore global transform if needed
+ if child_node is Node3D and global_transform and keep_global_transform:
+ child_node.global_transform = global_transform
+ elif child_node is Node2D and global_transform and keep_global_transform:
+ child_node.global_transform = global_transform
+
+ return {"message": "Set parent of '" + child_name + "' to '" + parent_name + "'"}
+
+# Helper function to set owner recursively
+func _set_owner_recursive(node, owner):
+ for child in node.get_children():
+ child.owner = owner
+ _set_owner_recursive(child, owner)
+
+
+
+
+func handle_create_child_object(params):
+ """Create a new object as a child of an existing node."""
+ if not params.has("parent_name"):
+ return {"error": "Missing required parameter: parent_name"}
+
+ var parent_name = params.parent_name
+ var type = params.get("type", "EMPTY")
+ var name = params.get("name", "")
+ var location = params.get("location", [0, 0, 0])
+ var rotation = params.get("rotation", [0, 0, 0])
+ var scale = params.get("scale", [1, 1, 1])
+ var replace_if_exists = params.get("replace_if_exists", false)
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find parent node with proper path handling
+ var parent_node = null
+
+ if parent_name == "root":
+ parent_node = current_scene
+ else:
+ # Check if it's a path (contains /)
+ if "/" in parent_name:
+ # Try direct path lookup first
+ parent_node = current_scene.get_node_or_null(parent_name)
+
+ # If that fails, try with leading slash
+ if not parent_node and not parent_name.begins_with("/"):
+ parent_node = current_scene.get_node_or_null("/" + parent_name)
+
+ # If still not found, try parsing the path components
+ if not parent_node:
+ var parts = parent_name.split("/")
+ var current = current_scene
+
+ for part in parts:
+ if part == "":
+ continue
+
+ var found = false
+ for child in current.get_children():
+ if child.name == part:
+ current = child
+ found = true
+ break
+
+ if not found:
+ # Try searching recursively for this part
+ var found_node = current.find_child(part, true, false)
+ if found_node:
+ current = found_node
+ else:
+ # Part not found, path is invalid
+ return {"error": "Could not find part '" + part + "' in path: " + parent_name}
+
+ parent_node = current
+ else:
+ # Simple name lookup for non-path names
+ parent_node = current_scene.find_child(parent_name, true, false)
+
+ if not parent_node:
+ return {"error": "Parent node not found: " + parent_name}
+
+ # Check if name already exists and handle replacement
+ if name != "":
+ var existing_node = current_scene.find_child(name, true, false)
+ if existing_node and not replace_if_exists:
+ return {"error": "Node with name '" + name + "' already exists. Use replace_if_exists=true to replace it."}
+ elif existing_node and replace_if_exists:
+ existing_node.queue_free()
+
+ # Create the node based on type
+ var node
+ var upper_type = type.to_upper()
+
+ # Handle common 3D node types
+ match upper_type:
+ "NODE", "EMPTY":
+ node = Node3D.new()
+ "NODE3D":
+ node = Node3D.new()
+ "SPATIAL": # For compatibility with Godot 3.x terminology
+ node = Node3D.new()
+ "MESH", "MESHINSTANCE3D":
+ node = MeshInstance3D.new()
+ "CUBE", "BOX":
+ node = MeshInstance3D.new()
+ node.mesh = BoxMesh.new()
+ "SPHERE":
+ node = MeshInstance3D.new()
+ node.mesh = SphereMesh.new()
+ "CYLINDER":
+ node = MeshInstance3D.new()
+ node.mesh = CylinderMesh.new()
+ "PLANE":
+ node = MeshInstance3D.new()
+ node.mesh = PlaneMesh.new()
+ "CAMERA", "CAMERA3D":
+ node = Camera3D.new()
+ "LIGHT", "DIRECTIONALLIGHT", "DIRECTIONALLIGHT3D":
+ node = DirectionalLight3D.new()
+ "SPOTLIGHT", "SPOTLIGHT3D":
+ node = SpotLight3D.new()
+ "OMNILIGHT", "OMNILIGHT3D":
+ node = OmniLight3D.new()
+ "RIGIDBODY", "RIGIDBODY3D":
+ node = RigidBody3D.new()
+ "STATICBODY", "STATICBODY3D":
+ node = StaticBody3D.new()
+ "CHARACTERBODY", "CHARACTERBODY3D":
+ node = CharacterBody3D.new()
+ "AREA", "AREA3D":
+ node = Area3D.new()
+ "COLLISION", "COLLISIONSHAPE3D":
+ node = CollisionShape3D.new()
+ # Add a default sphere shape
+ node.shape = SphereShape3D.new()
+
+ # Handle common 2D node types
+ "NODE2D":
+ node = Node2D.new()
+ "SPRITE", "SPRITE2D":
+ node = Sprite2D.new()
+ "CAMERA2D":
+ node = Camera2D.new()
+ "AREA2D":
+ node = Area2D.new()
+ "COLLISION2D", "COLLISIONSHAPE2D":
+ node = CollisionShape2D.new()
+ # Add a default circle shape
+ node.shape = CircleShape2D.new()
+ "RIGIDBODY2D":
+ node = RigidBody2D.new()
+ "STATICBODY2D":
+ node = StaticBody2D.new()
+ "CHARACTERBODY2D":
+ node = CharacterBody2D.new()
+
+ # Handle UI node types
+ "CONTROL":
+ node = Control.new()
+ "PANEL":
+ node = Panel.new()
+ "BUTTON":
+ node = Button.new()
+ "LABEL":
+ node = Label.new()
+ "LINEEDIT":
+ node = LineEdit.new()
+ "TEXTEDIT":
+ node = TextEdit.new()
+ "CONTAINER":
+ node = Container.new()
+ "VBOX", "VBOXCONTAINER":
+ node = VBoxContainer.new()
+ "HBOX", "HBOXCONTAINER":
+ node = HBoxContainer.new()
+ _:
+ # Try to create the node directly by class name using ClassDB
+ if ClassDB.class_exists(type) and ClassDB.can_instantiate(type):
+ node = ClassDB.instantiate(type)
+ else:
+ return {"error": "Unsupported object type: " + type}
+
+ # Set node name if provided
+ if name != "":
+ node.name = name
+
+ # Add to parent node directly
+ parent_node.add_child(node)
+ node.owner = current_scene
+
+ # Recursively set ownership for all children
+ _set_owner_recursive(node, current_scene)
+
+ # Set transform for Node3D or Node2D objects
+ if node is Node3D:
+ node.position = Vector3(location[0], location[1], location[2])
+ node.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2])
+ node.scale = Vector3(scale[0], scale[1], scale[2])
+ elif node is Node2D:
+ node.position = Vector2(location[0], location[1])
+ node.rotation_degrees = rotation[0]
+ node.scale = Vector2(scale[0], scale[1])
+
+ # In the JSON output, include the full path to the parent to help with debugging
+ var parent_path = current_scene.get_path_to(parent_node)
+
+ return {
+ "name": node.name,
+ "type": node.get_class(),
+ "path": current_scene.get_path_to(node),
+ "parent": parent_node.name,
+ "parent_path": parent_path
+ }
+
+
+
+
+
+
+
+
+
+
+#func handle_create_child_object(params):
+ #"""Create a new object as a child of an existing node."""
+ #if not params.has("parent_name"):
+ #return {"error": "Missing required parameter: parent_name"}
+ #
+ #var parent_name = params.parent_name
+ #var type = params.get("type", "EMPTY")
+ #var name = params.get("name", "")
+ #var location = params.get("location", [0, 0, 0])
+ #var rotation = params.get("rotation", [0, 0, 0])
+ #var scale = params.get("scale", [1, 1, 1])
+ #var replace_if_exists = params.get("replace_if_exists", false)
+ #
+ #var editor_interface = editor_plugin.get_editor_interface()
+ #var current_scene = editor_interface.get_edited_scene_root()
+ #
+ #if current_scene == null:
+ #return {"error": "No scene is currently open"}
+ #
+ ## Find parent node
+ #var parent_node
+ #if parent_name == "root":
+ #parent_node = current_scene
+ #else:
+ #parent_node = current_scene.find_child(parent_name, true, false)
+ #
+ #if not parent_node:
+ #return {"error": "Parent node not found: " + parent_name}
+ #
+ ## Check if name already exists and handle replacement
+ #if name != "":
+ #var existing_node = current_scene.find_child(name, true, false)
+ #if existing_node and not replace_if_exists:
+ #return {"error": "Node with name '" + name + "' already exists. Use replace_if_exists=true to replace it."}
+ #elif existing_node and replace_if_exists:
+ #existing_node.queue_free()
+ #
+ ## Create the node based on type (same logic as handle_create_object)
+ #var node
+ #var upper_type = type.to_upper()
+ #
+ ## Handle common 3D node types
+ #match upper_type:
+ #"NODE", "EMPTY":
+ #node = Node3D.new()
+ ## (rest of the type matching code from handle_create_object)
+ ## ...
+ #_:
+ ## Try to create the node directly by class name using ClassDB
+ #if ClassDB.class_exists(type) and ClassDB.can_instantiate(type):
+ #node = ClassDB.instantiate(type)
+ #else:
+ #return {"error": "Unsupported object type: " + type}
+ #
+ ## Set node name if provided
+ #if name != "":
+ #node.name = name
+ #
+ ## Add to parent node directly
+ #parent_node.add_child(node)
+ #node.owner = current_scene
+ #
+ ## Set transform for Node3D or Node2D objects
+ #if node is Node3D:
+ #node.position = Vector3(location[0], location[1], location[2])
+ #node.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2])
+ #node.scale = Vector3(scale[0], scale[1], scale[2])
+ #elif node is Node2D:
+ #node.position = Vector2(location[0], location[1])
+ #node.rotation_degrees = rotation[0]
+ #node.scale = Vector2(scale[0], scale[1])
+ #
+ #return {
+ #"name": node.name,
+ #"type": node.get_class(),
+ #"path": current_scene.get_path_to(node),
+ #"parent": parent_name
+ #}
+
+func handle_set_mesh(params):
+ """Create and set a mesh on a MeshInstance3D node."""
+ if not params.has("node_name") or not params.has("mesh_type"):
+ return {"error": "Missing required parameters: node_name and mesh_type"}
+
+ var node_name = params.node_name
+ var mesh_type = params.mesh_type
+
+ print("handle_set_mesh called for node: ", node_name, " mesh_type: ", mesh_type)
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find node using the improved node lookup
+ var node = _find_node(current_scene, node_name)
+ if not node:
+ return {"error": "Node not found: " + node_name}
+
+ print("Node found: ", node.name, " type: ", node.get_class())
+
+ # Check if the node can have a mesh property
+ if not node is MeshInstance3D:
+ return {"error": "Node is not a MeshInstance3D: " + node_name}
+
+ # Create mesh based on type
+ var mesh
+ match mesh_type.to_upper():
+ "CAPSULEMESH":
+ mesh = CapsuleMesh.new()
+ if params.has("radius"):
+ mesh.radius = float(params.radius)
+ if params.has("height"):
+ mesh.height = float(params.height)
+ "BOXMESH":
+ mesh = BoxMesh.new()
+ if params.has("size"):
+ var size = params.size
+ mesh.size = Vector3(float(size[0]), float(size[1]), float(size[2]))
+ "SPHEREMESH":
+ mesh = SphereMesh.new()
+ if params.has("radius"):
+ mesh.radius = float(params.radius)
+ "CYLINDERMESH":
+ mesh = CylinderMesh.new()
+ if params.has("radius"):
+ mesh.radius = float(params.radius)
+ if params.has("height"):
+ mesh.height = float(params.height)
+ "PLANEMESH":
+ mesh = PlaneMesh.new()
+ if params.has("size"):
+ var size = params.size
+ mesh.size = Vector2(float(size[0]), float(size[1]))
+ _:
+ return {"error": "Unsupported mesh type: " + mesh_type}
+
+ # Set the mesh
+ node.mesh = mesh
+
+ return {"message": "Set " + mesh_type + " on " + node_name}
+
+
+func _find_node(root, name_or_path):
+ # Check if it's a path (contains /)
+ if "/" in name_or_path:
+ # Try direct path lookup first
+ var node = root.get_node_or_null(name_or_path)
+ if node:
+ return node
+
+ # Try as relative path
+ if not name_or_path.begins_with("/"):
+ node = root.get_node_or_null("/" + name_or_path)
+ if node:
+ return node
+
+ # Try all combinations of path separators
+ var parts = name_or_path.split("/")
+ var current = root
+
+ for part in parts:
+ # Look for the next part in current node's children
+ var found = false
+ for child in current.get_children():
+ if child.name == part:
+ current = child
+ found = true
+ break
+
+ if not found:
+ # Try searching recursively
+ var found_node = current.find_child(part, true, false)
+ if found_node:
+ current = found_node
+ else:
+ return null
+
+ return current
+ else:
+ # Simple name lookup
+ return root.find_child(name_or_path, true, false)
+
+
+func handle_set_collision_shape(params):
+ """Create and set a collision shape on a CollisionShape3D or CollisionShape2D node."""
+ if not params.has("node_name") or not params.has("shape_type"):
+ return {"error": "Missing required parameters: node_name and shape_type"}
+
+ var node_name = params.node_name
+ var shape_type = params.shape_type
+ var shape_params = params.get("shape_params", {})
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find node by name or path
+ var node = _find_node(current_scene, node_name)
+ if not node:
+ return {"error": "Node not found: " + node_name}
+
+ # Check if the node can have a shape property
+ if not (node is CollisionShape3D or node is CollisionShape2D):
+ return {"error": "Node is not a CollisionShape: " + node_name}
+
+ # Create shape based on type
+ var shape
+ match shape_type.to_upper():
+ "CAPSULESHAPE3D":
+ shape = CapsuleShape3D.new()
+ if shape_params.has("radius"):
+ shape.radius = float(shape_params.radius)
+ if shape_params.has("height"):
+ shape.height = float(shape_params.height)
+ "BOXSHAPE3D":
+ shape = BoxShape3D.new()
+ if shape_params.has("size"):
+ var size = shape_params.size
+ shape.size = Vector3(float(size[0]), float(size[1]), float(size[2]))
+ "SPHERESHAPE3D":
+ shape = SphereShape3D.new()
+ if shape_params.has("radius"):
+ shape.radius = float(shape_params.radius)
+ "CYLINDERSHAPE3D":
+ shape = CylinderShape3D.new()
+ if shape_params.has("radius"):
+ shape.radius = float(shape_params.radius)
+ if shape_params.has("height"):
+ shape.height = float(shape_params.height)
+ "WORLDBOUNDARYSHAPE3D":
+ shape = WorldBoundaryShape3D.new()
+ # 2D Shapes
+ "CIRCLESHAPE2D":
+ shape = CircleShape2D.new()
+ if shape_params.has("radius"):
+ shape.radius = float(shape_params.radius)
+ "RECTANGLESHAPE2D":
+ shape = RectangleShape2D.new()
+ if shape_params.has("size"):
+ var size = shape_params.size
+ shape.size = Vector2(float(size[0]), float(size[1]))
+ "CAPSULESHAPE2D":
+ shape = CapsuleShape2D.new()
+ if shape_params.has("radius"):
+ shape.radius = float(shape_params.radius)
+ if shape_params.has("height"):
+ shape.height = float(shape_params.height)
+ _:
+ return {"error": "Unsupported shape type: " + shape_type}
+
+ # Set the shape
+ node.shape = shape
+
+ return {"message": "Set " + shape_type + " on " + node_name}
+
+
+#func handle_set_nested_property(params):
+ #"""Set a nested property like environment/sky/sky_material on a node."""
+ #if not params.has("node_name") or not params.has("property_name") or not params.has("value"):
+ #return {"error": "Missing required parameters: node_name, property_name, and value"}
+ #
+ #var node_name = params.node_name
+ #var property_path = params.property_name
+ #var value = params.value
+ #var value_type = params.get("value_type", "")
+ #
+ #var editor_interface = editor_plugin.get_editor_interface()
+ #var current_scene = editor_interface.get_edited_scene_root()
+ #
+ #if current_scene == null:
+ #return {"error": "No scene is currently open"}
+ #
+ ## Find node by name or path
+ #var node = null
+ #
+ ## Check if it's a path (contains /)
+ #if "/" in node_name:
+ ## Try direct path lookup first
+ #node = current_scene.get_node_or_null(node_name)
+ #
+ ## If that fails, try with leading slash
+ #if not node and not node_name.begins_with("/"):
+ #node = current_scene.get_node_or_null("/" + node_name)
+ #
+ ## Try other lookup methods if needed...
+ #else:
+ ## Simple name lookup for non-path names
+ #node = current_scene.find_child(node_name, true, false)
+ #
+ #if not node:
+ #return {"error": "Node not found: " + node_name}
+ #
+ ## Split the property path
+ #var property_parts = property_path.split("/")
+ #
+ ## Handle special cases for common node types
+ #if node is WorldEnvironment:
+ ## Make sure the environment exists
+ #if not node.environment:
+ #node.environment = Environment.new()
+ #
+ ## Handle environment properties
+ #if property_parts.size() >= 1 and property_parts[0] == "environment":
+ #var env = node.environment
+ #
+ ## Handle special case for sky material
+ #if property_parts.size() >= 3 and property_parts[1] == "sky" and property_parts[2] == "sky_material":
+ ## Make sure sky exists
+ #if not env.sky:
+ #env.sky = Sky.new()
+ #
+ #if property_parts.size() == 3:
+ ## Create the material based on type
+ #var material = null
+ #if value == "ProceduralSkyMaterial":
+ #material = ProceduralSkyMaterial.new()
+ #elif value == "PanoramaSkyMaterial":
+ #material = PanoramaSkyMaterial.new()
+ #elif value == "PhysicalSkyMaterial":
+ #material = PhysicalSkyMaterial.new()
+ #else:
+ #return {"error": "Unknown sky material type: " + str(value)}
+ #
+ ## Set the sky material
+ #env.sky.sky_material = material
+ #return {"message": "Set sky material to " + str(value)}
+ #
+ ## Handle sky material properties (e.g., environment/sky/sky_material/sky_top_color)
+ #elif property_parts.size() >= 4 and env.sky.sky_material:
+ #var material = env.sky.sky_material
+ #var mat_prop = property_parts[3]
+ #
+ ## Verify property exists on the material
+ #if not mat_prop in material:
+ #return {"error": "Property not found on sky material: " + mat_prop}
+ #
+ ## Handle color properties
+ #if typeof(material.get(mat_prop)) == TYPE_COLOR:
+ #if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ #if value.size() >= 4:
+ #material.set(mat_prop, Color(value[0], value[1], value[2], value[3]))
+ #else:
+ #material.set(mat_prop, Color(value[0], value[1], value[2]))
+ #return {"message": "Set sky material property " + mat_prop + " to " + str(value)}
+ #else:
+ ## Set other property types
+ #material.set(mat_prop, value)
+ #return {"message": "Set sky material property " + mat_prop + " to " + str(value)}
+ #
+ ## Handle direct environment properties
+ #elif property_parts.size() == 2:
+ #var prop = property_parts[1]
+ #
+ ## Map common property names to actual Godot property names
+ #match prop:
+ #"background_color":
+ #prop = "background_color"
+ #"ambient_light_color":
+ #prop = "ambient_light_color"
+ #"fog_color":
+ #prop = "fog_color"
+ #"background_mode":
+ #prop = "background_mode"
+ ## Add more mappings as needed
+ #
+ ## Verify property exists
+ #if not prop in env:
+ #var available_props = []
+ #for p in ["background_color", "ambient_light_color", "fog_color", "background_mode",
+ #"fog_enabled", "fog_density", "glow_enabled", "glow_intensity",
+ #"adjustment_enabled", "tonemap_mode"]:
+ #if p in env:
+ #available_props.append(p)
+ #return {"error": "Property not found on environment: " + prop +
+ #". Available properties include: " + str(available_props)}
+ #
+ ## Convert value based on property type
+ #var converted_value = value
+ #var current_value = env.get(prop)
+ #
+ ## Handle different types
+ #if typeof(current_value) == TYPE_COLOR:
+ #if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ #if value.size() >= 4:
+ #converted_value = Color(value[0], value[1], value[2], value[3])
+ #else:
+ #converted_value = Color(value[0], value[1], value[2])
+ #elif typeof(current_value) == TYPE_BOOL:
+ #if typeof(value) == TYPE_STRING:
+ #converted_value = (value.to_lower() == "true")
+ #else:
+ #converted_value = bool(value)
+ #elif typeof(current_value) == TYPE_INT:
+ #converted_value = int(value)
+ #elif typeof(current_value) == TYPE_FLOAT:
+ #converted_value = float(value)
+ #
+ ## Set the property
+ #env.set(prop, converted_value)
+ #return {"message": "Set environment property " + prop + " to " + str(converted_value)}
+ #
+ ## Handle environment sub-objects (e.g., environment/fog/enabled)
+ #elif property_parts.size() == 3:
+ #var sub_obj = property_parts[1]
+ #var sub_prop = property_parts[2]
+ #
+ ## Map common property paths
+ #if sub_obj == "fog" and sub_prop == "enabled":
+ #env.fog_enabled = bool(value)
+ #return {"message": "Set fog_enabled to " + str(bool(value))}
+ #elif sub_obj == "glow" and sub_prop == "enabled":
+ #env.glow_enabled = bool(value)
+ #return {"message": "Set glow_enabled to " + str(bool(value))}
+ #elif sub_obj == "ambient_light" and sub_prop == "color":
+ #if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ #env.ambient_light_color = Color(value[0], value[1], value[2])
+ #return {"message": "Set ambient_light_color to " + str(value)}
+ #
+ ## Generic property mapping attempt
+ #var full_prop = sub_obj + "_" + sub_prop
+ #if full_prop in env:
+ #env.set(full_prop, value)
+ #return {"message": "Set " + full_prop + " to " + str(value)}
+ #
+ #return {"error": "Unsupported environment property path: " + property_path}
+ #
+ ## Handle other node types here
+ ## ...
+ #
+ #return {"error": "Unsupported nested property path: " + property_path}
+
+
+func handle_set_nested_property(params):
+ """Set a nested property like environment/sky/sky_material on a node."""
+ if not params.has("node_name") or not params.has("property_name") or not params.has("value"):
+ return {"error": "Missing required parameters: node_name, property_name, and value"}
+
+ var node_name = params.node_name
+ var property_path = params.property_name
+ var value = params.value
+ var value_type = params.get("value_type", "")
+
+ print("Setting nested property: ", property_path, " to ", value, " on ", node_name)
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Find node by name or path
+ var node = null
+
+ # Check if it's a path (contains /)
+ if "/" in node_name:
+ # Try direct path lookup first
+ node = current_scene.get_node_or_null(node_name)
+
+ # If that fails, try with leading slash
+ if not node and not node_name.begins_with("/"):
+ node = current_scene.get_node_or_null("/" + node_name)
+
+ # Try parsing the path manually if needed
+ if not node:
+ # Try other path resolution methods...
+ pass
+ else:
+ # Simple name lookup for non-path names
+ node = current_scene.find_child(node_name, true, false)
+
+ if not node:
+ return {"error": "Node not found: " + node_name}
+
+ # Split the property path
+ var property_parts = property_path.split("/")
+
+ # Handle WorldEnvironment properties
+ if node is WorldEnvironment:
+ return _handle_worldenvironment_properties(node, property_parts, value)
+
+ # Add other node type handlers here
+
+ # Default case for simple properties
+ return {"error": "Unsupported node type for nested properties: " + node.get_class()}
+
+func _handle_worldenvironment_properties(node, property_parts, value):
+ """Handle nested properties for WorldEnvironment nodes."""
+ # Make sure the environment exists
+ if not node.environment:
+ node.environment = Environment.new()
+
+ # Get environment resource for easier access
+ var env = node.environment
+
+ # First level should be "environment"
+ if property_parts.size() < 1 or property_parts[0] != "environment":
+ return {"error": "WorldEnvironment properties must start with 'environment/'"}
+
+ # Handle sky material properties (4-part paths)
+ if property_parts.size() == 4 and property_parts[1] == "sky" and property_parts[2] == "sky_material":
+ # Make sure sky exists
+ if not env.sky:
+ env.sky = Sky.new()
+
+ # Make sure sky material exists
+ if not env.sky.sky_material:
+ env.sky.sky_material = ProceduralSkyMaterial.new()
+
+ var material = env.sky.sky_material
+ var property_name = property_parts[3]
+
+ # Debug info
+ print("Trying to set sky material property: ", property_name)
+ print("Material class: ", material.get_class())
+
+ # Direct property handling for cloud properties
+ if property_name == "use_clouds" or property_name == "clouds_enabled":
+ # Enable/disable clouds directly
+ material.use_clouds = bool(value)
+ return {"message": "Set use_clouds to " + str(bool(value))}
+
+ elif property_name == "cloud_color":
+ # Set cloud color directly
+ if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ material.cloud_color = Color(float(value[0]), float(value[1]), float(value[2]))
+ return {"message": "Set cloud_color to " + str(material.cloud_color)}
+
+ elif property_name == "cloud_coverage":
+ # Set cloud coverage directly
+ material.cloud_coverage = float(value)
+ return {"message": "Set cloud_coverage to " + str(float(value))}
+
+ elif property_name == "cloud_size":
+ # Set cloud size directly
+ material.cloud_size = float(value)
+ return {"message": "Set cloud_size to " + str(float(value))}
+
+ # Handle other properties with the property map
+ var property_map = {
+ # Sky colors
+ "sky_top_color": "sky_top_color",
+ "sky_horizon_color": "sky_horizon_color",
+ "sky_curve": "sky_curve",
+ "ground_horizon_color": "ground_horizon_color",
+ "ground_bottom_color": "ground_bottom_color",
+ "ground_curve": "ground_curve",
+
+ # Sun properties
+ "sun_angle_max": "sun_angle_max",
+ "sun_curve": "sun_curve"
+ }
+
+ # Check if property name needs to be mapped
+ if property_name in property_map:
+ property_name = property_map[property_name]
+
+ # Try to set the property with property-specific type conversion
+ var current_value = material.get(property_name)
+ var converted_value = value
+
+ # Handle different property types
+ match typeof(current_value):
+ TYPE_BOOL:
+ converted_value = bool(value)
+ TYPE_INT:
+ converted_value = int(value)
+ TYPE_FLOAT:
+ converted_value = float(value)
+ TYPE_COLOR:
+ if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ if value.size() >= 4:
+ converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3]))
+ else:
+ converted_value = Color(float(value[0]), float(value[1]), float(value[2]))
+
+ # Set the property
+ material.set(property_name, converted_value)
+ return {"message": "Set sky material property " + property_name + " to " + str(converted_value)}
+
+ return {"error": "Unknown sky material property: " + property_name + ". Available cloud properties: use_clouds, cloud_color, cloud_coverage, cloud_size"}
+
+ # Handle sky material type (3-part paths)
+ elif property_parts.size() == 3 and property_parts[1] == "sky" and property_parts[2] == "sky_material":
+ # Make sure sky exists
+ if not env.sky:
+ env.sky = Sky.new()
+
+ # Create the material based on type
+ var material = null
+
+ if value == "ProceduralSkyMaterial":
+ material = ProceduralSkyMaterial.new()
+ elif value == "PanoramaSkyMaterial":
+ material = PanoramaSkyMaterial.new()
+ elif value == "PhysicalSkyMaterial":
+ material = PhysicalSkyMaterial.new()
+ else:
+ return {"error": "Unknown sky material type: " + str(value)}
+
+ # Set the sky material
+ env.sky.sky_material = material
+ return {"message": "Set sky material to " + str(value)}
+
+ # Handle direct environment properties (2-part paths)
+ elif property_parts.size() == 2:
+ var property_name = property_parts[1]
+
+ # Map common property name variations to actual property names
+ var property_map = {
+ "background": "background_mode",
+ "ambient_light_color": "ambient_light_color",
+ "background_color": "background_color",
+ "fog_enabled": "fog_enabled",
+ "glow_enabled": "glow_enabled",
+ "glow_intensity": "glow_intensity",
+ "fog_density": "fog_density",
+ "fog_color": "fog_color",
+ "volumetric_fog_enabled": "volumetric_fog_enabled",
+ "volumetric_fog_density": "volumetric_fog_density",
+ "volumetric_fog_albedo": "volumetric_fog_albedo"
+ }
+
+ # Check if we need to map the property name
+ if property_name in property_map:
+ property_name = property_map[property_name]
+
+ # Verify property exists
+ if not property_name in env:
+ return {"error": "Property not found on environment: " + property_name}
+
+ # Convert value based on property type
+ var converted_value = value
+ var current_value = env.get(property_name)
+
+ # Handle different property types
+ match typeof(current_value):
+ TYPE_BOOL:
+ converted_value = bool(value)
+ TYPE_INT:
+ converted_value = int(value)
+ TYPE_FLOAT:
+ converted_value = float(value)
+ TYPE_COLOR:
+ if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ if value.size() >= 4:
+ converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3]))
+ else:
+ converted_value = Color(float(value[0]), float(value[1]), float(value[2]))
+
+ # Set the property
+ env.set(property_name, converted_value)
+ return {"message": "Set environment property " + property_name + " to " + str(converted_value)}
+
+ # Handle fog properties (3-part paths)
+ elif property_parts.size() == 3 and property_parts[1] == "fog":
+ var prop = property_parts[2]
+
+ if prop == "enabled":
+ env.fog_enabled = bool(value)
+ return {"message": "Set fog_enabled to " + str(bool(value))}
+ elif prop == "density":
+ env.fog_density = float(value)
+ return {"message": "Set fog_density to " + str(float(value))}
+ elif prop == "color":
+ if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ env.fog_color = Color(float(value[0]), float(value[1]), float(value[2]))
+ return {"message": "Set fog_color to " + str(env.fog_color)}
+ else:
+ # Try a direct combined property name
+ var combined_prop = "fog_" + prop
+ if combined_prop in env:
+ env.set(combined_prop, value)
+ return {"message": "Set " + combined_prop + " to " + str(value)}
+ return {"error": "Unknown fog property: " + prop}
+
+ # Handle volumetric fog properties (3-part paths)
+ elif property_parts.size() == 3 and property_parts[1] == "volumetric_fog":
+ var prop = property_parts[2]
+
+ if prop == "enabled":
+ env.volumetric_fog_enabled = bool(value)
+ return {"message": "Set volumetric_fog_enabled to " + str(bool(value))}
+ elif prop == "density":
+ env.volumetric_fog_density = float(value)
+ return {"message": "Set volumetric_fog_density to " + str(float(value))}
+ elif prop == "albedo":
+ if typeof(value) == TYPE_ARRAY and value.size() >= 3:
+ env.volumetric_fog_albedo = Color(float(value[0]), float(value[1]), float(value[2]))
+ return {"message": "Set volumetric_fog_albedo to " + str(env.volumetric_fog_albedo)}
+ else:
+ # Try a direct combined property name
+ var combined_prop = "volumetric_fog_" + prop
+ if combined_prop in env:
+ env.set(combined_prop, value)
+ return {"message": "Set " + combined_prop + " to " + str(value)}
+ return {"error": "Unknown volumetric fog property: " + prop}
+
+ # Other environment properties can be added here
+
+ return {"error": "Unsupported property path: " + property_parts.join("/")}
+
+# Add the following function:
+func handle_delete_script(params):
+ if not params.has("script_path"):
+ return {"error": "Missing required parameter: script_path"}
+
+ var script_path = params.script_path
+
+ # Check if file exists
+ if not FileAccess.file_exists(script_path):
+ return {"error": "Script file does not exist: " + script_path}
+
+ # Delete the file
+ var error = DirAccess.remove_absolute(script_path)
+ if error != OK:
+ return {"error": "Failed to delete script file: " + script_path + " (Error code: " + str(error) + ")"}
+
+ return {
+ "message": "Script deleted successfully: " + script_path
+ }
+
+# Add the following function near the handle_delete_script function:
+func handle_delete_file(params):
+ if not params.has("file_path"):
+ return {"error": "Missing required parameter: file_path"}
+
+ var file_path = params.file_path
+
+ # Check if file exists
+ if not FileAccess.file_exists(file_path):
+ return {"error": "File does not exist: " + file_path}
+
+ # Delete the file
+ var error = DirAccess.remove_absolute(file_path)
+ if error != OK:
+ return {"error": "Failed to delete file: " + file_path + " (Error code: " + str(error) + ")"}
+
+ return {
+ "message": "File deleted successfully: " + file_path
+ }
+
+func handle_show_message(params):
+ if not params.has("message"):
+ return {"error": "Missing required parameter: message"}
+
+ var message = params.message
+
+ # Show a message dialog
+ editor_plugin.get_editor_interface().show_message_notification(message)
+
+ return {"message": "Message shown successfully"}
+
+func handle_reimport_asset(params):
+ if not params.has("asset_path"):
+ return {"error": "Missing required parameter: asset_path"}
+
+ var asset_path = params.asset_path
+
+ # Force a filesystem scan to detect the new file
+ var editor_filesystem = editor_plugin.get_editor_interface().get_resource_filesystem()
+ if editor_filesystem:
+ editor_filesystem.scan()
+ # Also scan the specific directory
+ var dir_path = asset_path.get_base_dir()
+ editor_filesystem.scan_sources()
+
+ return {
+ "message": "Triggered filesystem scan for: " + asset_path
+ }
+ else:
+ return {"error": "Could not access editor filesystem"}
+
+func handle_import_glb_scene(params):
+ if not params.has("glb_path"):
+ return {"error": "Missing required parameter: glb_path"}
+
+ var glb_path = params.glb_path
+ var name = params.get("name", "")
+ var position = params.get("position", [0, 0, 0])
+ var rotation = params.get("rotation", [0, 0, 0])
+ var scale = params.get("scale", [1, 1, 1])
+
+ var editor_interface = editor_plugin.get_editor_interface()
+ var current_scene = editor_interface.get_edited_scene_root()
+
+ if current_scene == null:
+ return {"error": "No scene is currently open"}
+
+ # Check if the GLB file exists
+ if not FileAccess.file_exists(glb_path):
+ return {"error": "GLB file not found: " + glb_path}
+
+ # Try to load the GLB as a PackedScene
+ var glb_scene = load(glb_path)
+ if not glb_scene:
+ return {"error": "Failed to load GLB file: " + glb_path}
+
+ # Check if it's a PackedScene
+ if glb_scene is PackedScene:
+ # Instantiate the packed scene
+ var instance = glb_scene.instantiate()
+ if not instance:
+ return {"error": "Failed to instantiate GLB scene"}
+
+ # Set the name
+ if name != "":
+ instance.name = name
+ else:
+ # Use filename without extension
+ var filename = glb_path.get_file().get_basename()
+ instance.name = filename
+
+ # Add to current scene
+ current_scene.add_child(instance)
+ instance.owner = current_scene
+
+ # Set ownership recursively for all children
+ _set_owner_recursive(instance, current_scene)
+
+ # Set transform if it's a 3D node
+ if instance is Node3D:
+ instance.position = Vector3(position[0], position[1], position[2])
+ instance.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2])
+ instance.scale = Vector3(scale[0], scale[1], scale[2])
+
+ return {
+ "success": true,
+ "instance_name": instance.name,
+ "message": "GLB scene imported successfully as: " + instance.name
+ }
+ else:
+ # If it's not a PackedScene, try to create a MeshInstance3D
+ var node_name = name if name != "" else glb_path.get_file().get_basename()
+
+ # Create a MeshInstance3D
+ var mesh_instance = MeshInstance3D.new()
+ mesh_instance.name = node_name
+
+ # Try to set the mesh
+ if glb_scene is Mesh:
+ mesh_instance.mesh = glb_scene
+ else:
+ # Try loading it differently
+ return {"error": "GLB file is not a PackedScene or Mesh resource"}
+
+ # Add to scene
+ current_scene.add_child(mesh_instance)
+ mesh_instance.owner = current_scene
+
+ # Set transform
+ mesh_instance.position = Vector3(position[0], position[1], position[2])
+ mesh_instance.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2])
+ mesh_instance.scale = Vector3(scale[0], scale[1], scale[2])
+
+ return {
+ "success": true,
+ "instance_name": mesh_instance.name,
+ "message": "GLB imported as MeshInstance3D: " + mesh_instance.name
+ }
diff --git a/game/addons/godot_mcp/command_handler.gd.uid b/game/addons/godot_mcp/command_handler.gd.uid
new file mode 100644
index 0000000..17838dd
--- /dev/null
+++ b/game/addons/godot_mcp/command_handler.gd.uid
@@ -0,0 +1 @@
+uid://mcipi7y664aq
diff --git a/game/addons/godot_mcp/plugin.cfg b/game/addons/godot_mcp/plugin.cfg
new file mode 100644
index 0000000..a382373
--- /dev/null
+++ b/game/addons/godot_mcp/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Godot MCP"
+description="A plugin to enable communication between Godot Editor and Model Context Protocol (MCP) clients."
+author="Your Name"
+version="0.1.0"
+script="plugin.gd"
diff --git a/game/addons/godot_mcp/plugin.gd b/game/addons/godot_mcp/plugin.gd
new file mode 100644
index 0000000..ba238eb
--- /dev/null
+++ b/game/addons/godot_mcp/plugin.gd
@@ -0,0 +1,140 @@
+# Structure for addons/godot_mcp/plugin.gd
+@tool
+extends EditorPlugin
+
+const SERVER_PORT = 6400
+var server: TCPServer = null
+var active_connections = []
+var command_handler
+
+func _enter_tree():
+ # Initialize the plugin
+ print("Godot MCP Plugin activated")
+
+ # Create command handler
+ command_handler = preload("res://addons/godot_mcp/command_handler.gd").new()
+ command_handler.set_editor_plugin(self)
+
+ # Start the TCP server
+ server = TCPServer.new()
+ var error = server.listen(SERVER_PORT)
+ if error != OK:
+ push_error("Failed to start Godot MCP Server on port %d: %s" % [SERVER_PORT, error])
+ return
+
+ print("Godot MCP Server listening on port %d" % SERVER_PORT)
+
+ # Add UI
+ add_control_to_bottom_panel(
+ preload("res://addons/godot_mcp/ui/mcp_panel.tscn").instantiate(),
+ "MCP"
+ )
+
+func _exit_tree():
+ # Clean up the plugin when disabled
+ if server:
+ server.stop()
+ server = null
+
+ for connection in active_connections:
+ if connection.get_status() == StreamPeerTCP.STATUS_CONNECTED:
+ connection.disconnect_from_host()
+
+ active_connections.clear()
+
+ # Remove UI
+ remove_control_from_bottom_panel(get_editor_interface().get_base_control().get_node("MCPPanel"))
+ print("Godot MCP Plugin deactivated")
+
+func _process(delta):
+ # Check for new connections
+ if server and server.is_connection_available():
+ var connection = server.take_connection()
+ if connection:
+ active_connections.append(connection)
+ print("New MCP connection established")
+
+ # Process existing connections
+ var i = 0
+ while i < active_connections.size():
+ var connection = active_connections[i]
+
+ # Check connection status
+ if connection.get_status() != StreamPeerTCP.STATUS_CONNECTED:
+ active_connections.remove_at(i)
+ print("MCP connection closed")
+ continue
+
+ # Check for incoming messages
+ if connection.get_available_bytes() > 0:
+ var data = _read_message(connection)
+ if data.size() > 0:
+ # Process the command
+ var response = _process_command(data)
+
+ # Send the response
+ _send_message(connection, response)
+
+ i += 1
+
+func _read_message(connection):
+ # Read data from the connection
+ var data = PackedByteArray()
+ var bytes_available = connection.get_available_bytes()
+
+ if bytes_available > 0:
+ data = connection.get_data(bytes_available)[1]
+
+ # Attempt to parse as JSON
+ var json_string = data.get_string_from_utf8()
+ var json = JSON.new()
+ var error = json.parse(json_string)
+
+ if error == OK:
+ return json.get_data()
+ else:
+ print("Failed to parse JSON: ", json.get_error_message())
+
+ return {}
+
+func _send_message(connection, data):
+ # Convert to JSON and send
+ var json_string = JSON.stringify(data)
+ connection.put_data(json_string.to_utf8_buffer())
+
+
+
+func _process_command(data):
+ # Process the command and return a response
+ if data == null or typeof(data) != TYPE_DICTIONARY:
+ return {
+ "status": "error",
+ "error": "Invalid command format. Expected a dictionary."
+ }
+
+ if not data.has("type") or not data.has("params"):
+ return {
+ "status": "error",
+ "error": "Invalid command format. Expected 'type' and 'params' fields."
+ }
+
+ var command_type = data["type"]
+ var params = data["params"]
+
+ if command_type == "ping":
+ return {"status": "success", "result": {"message": "pong"}}
+
+ # Forward to command handler
+ var result = command_handler.handle_command(command_type, params)
+
+ # Check if result is valid
+ if result == null:
+ return {
+ "status": "error",
+ "error": "Command handler returned null result"
+ }
+
+ if result.has("error"):
+ return {"status": "error", "error": result.error}
+ else:
+ return {"status": "success", "result": result}
diff --git a/game/addons/godot_mcp/plugin.gd.uid b/game/addons/godot_mcp/plugin.gd.uid
new file mode 100644
index 0000000..9df341b
--- /dev/null
+++ b/game/addons/godot_mcp/plugin.gd.uid
@@ -0,0 +1 @@
+uid://ddfhkiyge45tn
diff --git a/game/addons/godot_mcp/ui/mcp_panel.gd b/game/addons/godot_mcp/ui/mcp_panel.gd
new file mode 100644
index 0000000..c9a17d9
--- /dev/null
+++ b/game/addons/godot_mcp/ui/mcp_panel.gd
@@ -0,0 +1,77 @@
+# addons/godot_mcp/ui/mcp_panel.gd
+@tool
+extends Control
+
+var status_label: Label
+var port_field: SpinBox
+var start_button: Button
+var stop_button: Button
+var log_display: TextEdit
+
+func _ready():
+ # Set up references to UI elements
+ status_label = $VBoxContainer/StatusPanel/StatusLabel
+ port_field = $VBoxContainer/ConfigPanel/PortField
+ start_button = $VBoxContainer/ButtonPanel/StartButton
+ stop_button = $VBoxContainer/ButtonPanel/StopButton
+ log_display = $VBoxContainer/LogPanel/LogDisplay
+
+ # Initialize UI
+ port_field.value = 6400 # Default port
+ start_button.disabled = false
+ stop_button.disabled = true
+
+ # Connect signals
+ start_button.pressed.connect(_on_start_button_pressed)
+ stop_button.pressed.connect(_on_stop_button_pressed)
+
+ # Set initial status
+ update_status("Not running")
+ add_log_message("Godot MCP Plugin initialized")
+
+func update_status(status_text: String, is_error: bool = false):
+ status_label.text = "Status: " + status_text
+ if is_error:
+ status_label.add_theme_color_override("font_color", Color(1, 0.3, 0.3))
+ else:
+ status_label.remove_theme_color_override("font_color")
+
+func add_log_message(message: String):
+ var timestamp = Time.get_datetime_string_from_system()
+ log_display.text += "[" + timestamp + "] " + message + "\n"
+ log_display.scroll_vertical = log_display.get_line_count()
+
+func _on_start_button_pressed():
+ # This function will be called from the plugin.gd script
+ # when the server is actually started
+ update_status("Running on port " + str(port_field.value))
+ start_button.disabled = true
+ stop_button.disabled = false
+ add_log_message("MCP Server started on port " + str(port_field.value))
+
+func _on_stop_button_pressed():
+ # This function will be called from the plugin.gd script
+ # when the server is actually stopped
+ update_status("Stopped")
+ start_button.disabled = false
+ stop_button.disabled = true
+ add_log_message("MCP Server stopped")
+
+# Function to be called from plugin.gd when a client connects
+func on_client_connected():
+ add_log_message("Client connected")
+ update_status("Client connected")
+
+# Function to be called from plugin.gd when a client disconnects
+func on_client_disconnected():
+ add_log_message("Client disconnected")
+ update_status("Running (no clients)")
+
+# Function to be called from plugin.gd when a command is received
+func on_command_received(command_type, params):
+ add_log_message("Command received: " + command_type)
+
+# Function to be called from plugin.gd when a response is sent
+func on_response_sent(command_type, success):
+ var status = "Success" if success else "Failed"
+ add_log_message("Response sent for " + command_type + ": " + status)
\ No newline at end of file
diff --git a/game/addons/godot_mcp/ui/mcp_panel.gd.uid b/game/addons/godot_mcp/ui/mcp_panel.gd.uid
new file mode 100644
index 0000000..e80c22f
--- /dev/null
+++ b/game/addons/godot_mcp/ui/mcp_panel.gd.uid
@@ -0,0 +1 @@
+uid://bqabyd7ce60u0
diff --git a/game/addons/godot_mcp/ui/mcp_panel.tscn b/game/addons/godot_mcp/ui/mcp_panel.tscn
new file mode 100644
index 0000000..38c38f8
--- /dev/null
+++ b/game/addons/godot_mcp/ui/mcp_panel.tscn
@@ -0,0 +1,69 @@
+[gd_scene load_steps=2 format=3 uid="uid://dxvt86ck6b2a4"]
+
+[ext_resource type="Script" path="res://addons/godot_mcp/ui/mcp_panel.gd" id="1_4g23r"]
+
+[node name="MCPPanel" 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_4g23r")
+
+[node name="VBoxContainer" type="VBoxContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="StatusPanel" type="PanelContainer" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="StatusLabel" type="Label" parent="VBoxContainer/StatusPanel"]
+layout_mode = 2
+text = "Status: Not running"
+
+[node name="ConfigPanel" type="PanelContainer" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/ConfigPanel"]
+layout_mode = 2
+
+[node name="PortLabel" type="Label" parent="VBoxContainer/ConfigPanel/HBoxContainer"]
+layout_mode = 2
+text = "Port:"
+
+[node name="PortField" type="SpinBox" parent="VBoxContainer/ConfigPanel"]
+layout_mode = 2
+min_value = 1024.0
+max_value = 65535.0
+value = 6400.0
+alignment = 1
+
+[node name="ButtonPanel" type="PanelContainer" parent="VBoxContainer"]
+layout_mode = 2
+
+[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/ButtonPanel"]
+layout_mode = 2
+alignment = 1
+
+[node name="StartButton" type="Button" parent="VBoxContainer/ButtonPanel"]
+layout_mode = 2
+text = "Start Server"
+
+[node name="StopButton" type="Button" parent="VBoxContainer/ButtonPanel"]
+layout_mode = 2
+disabled = true
+text = "Stop Server"
+
+[node name="LogPanel" type="PanelContainer" parent="VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+
+[node name="LogDisplay" type="TextEdit" parent="VBoxContainer/LogPanel"]
+layout_mode = 2
+editable = false
+wrap_mode = 1
\ No newline at end of file
diff --git a/game/addons/simplegrasstextured/default_mesh_builder.gd.uid b/game/addons/simplegrasstextured/default_mesh_builder.gd.uid
index 0fed37c..62bb4fa 100644
--- a/game/addons/simplegrasstextured/default_mesh_builder.gd.uid
+++ b/game/addons/simplegrasstextured/default_mesh_builder.gd.uid
@@ -1 +1 @@
-uid://deh0tfs84csxo
+uid://deh0tfs84csxo
diff --git a/game/addons/simplegrasstextured/grass.gd.uid b/game/addons/simplegrasstextured/grass.gd.uid
index ba76e5b..9dd25ee 100644
--- a/game/addons/simplegrasstextured/grass.gd.uid
+++ b/game/addons/simplegrasstextured/grass.gd.uid
@@ -1 +1 @@
-uid://2juaclm8gc1n
+uid://2juaclm8gc1n
diff --git a/game/addons/simplegrasstextured/gui/about.gd.uid b/game/addons/simplegrasstextured/gui/about.gd.uid
index 24cd818..4d2a37b 100644
--- a/game/addons/simplegrasstextured/gui/about.gd.uid
+++ b/game/addons/simplegrasstextured/gui/about.gd.uid
@@ -1 +1 @@
-uid://cu72rjuvdnnx
+uid://cu72rjuvdnnx
diff --git a/game/addons/simplegrasstextured/gui/clear_all_confirmation_dialog.gd.uid b/game/addons/simplegrasstextured/gui/clear_all_confirmation_dialog.gd.uid
index 998fb42..a296237 100644
--- a/game/addons/simplegrasstextured/gui/clear_all_confirmation_dialog.gd.uid
+++ b/game/addons/simplegrasstextured/gui/clear_all_confirmation_dialog.gd.uid
@@ -1 +1 @@
-uid://wckg68rm05vd
+uid://wckg68rm05vd
diff --git a/game/addons/simplegrasstextured/gui/domain_range.gd.uid b/game/addons/simplegrasstextured/gui/domain_range.gd.uid
index 10ac00d..36cefd5 100644
--- a/game/addons/simplegrasstextured/gui/domain_range.gd.uid
+++ b/game/addons/simplegrasstextured/gui/domain_range.gd.uid
@@ -1 +1 @@
-uid://dmpm4vrmag0ru
+uid://dmpm4vrmag0ru
diff --git a/game/addons/simplegrasstextured/gui/global_parameters.gd.uid b/game/addons/simplegrasstextured/gui/global_parameters.gd.uid
index 4209641..6e19d31 100644
--- a/game/addons/simplegrasstextured/gui/global_parameters.gd.uid
+++ b/game/addons/simplegrasstextured/gui/global_parameters.gd.uid
@@ -1 +1 @@
-uid://b1ddeyowx86m3
+uid://b1ddeyowx86m3
diff --git a/game/addons/simplegrasstextured/gui/toolbar.gd.uid b/game/addons/simplegrasstextured/gui/toolbar.gd.uid
index 41f5c31..d9ace7f 100644
--- a/game/addons/simplegrasstextured/gui/toolbar.gd.uid
+++ b/game/addons/simplegrasstextured/gui/toolbar.gd.uid
@@ -1 +1 @@
-uid://usxgr64t746m
+uid://usxgr64t746m
diff --git a/game/addons/simplegrasstextured/gui/toolbar_menu.gd.uid b/game/addons/simplegrasstextured/gui/toolbar_menu.gd.uid
index 75d0d46..410a2f6 100644
--- a/game/addons/simplegrasstextured/gui/toolbar_menu.gd.uid
+++ b/game/addons/simplegrasstextured/gui/toolbar_menu.gd.uid
@@ -1 +1 @@
-uid://bk4wcnwns36qk
+uid://bk4wcnwns36qk
diff --git a/game/addons/simplegrasstextured/gui/toolbar_up.gd.uid b/game/addons/simplegrasstextured/gui/toolbar_up.gd.uid
index c1c757b..b873903 100644
--- a/game/addons/simplegrasstextured/gui/toolbar_up.gd.uid
+++ b/game/addons/simplegrasstextured/gui/toolbar_up.gd.uid
@@ -1 +1 @@
-uid://cdfq5jjtnlcwg
+uid://cdfq5jjtnlcwg
diff --git a/game/addons/simplegrasstextured/plugin.gd.uid b/game/addons/simplegrasstextured/plugin.gd.uid
index 1ef22d7..e19d5c1 100644
--- a/game/addons/simplegrasstextured/plugin.gd.uid
+++ b/game/addons/simplegrasstextured/plugin.gd.uid
@@ -1 +1 @@
-uid://60a3gi7u2kf
+uid://60a3gi7u2kf
diff --git a/game/addons/simplegrasstextured/sgt_inspector.gd.uid b/game/addons/simplegrasstextured/sgt_inspector.gd.uid
index 2ffc247..559e5eb 100644
--- a/game/addons/simplegrasstextured/sgt_inspector.gd.uid
+++ b/game/addons/simplegrasstextured/sgt_inspector.gd.uid
@@ -1 +1 @@
-uid://cp54123dwlv7j
+uid://cp54123dwlv7j
diff --git a/game/addons/simplegrasstextured/shaders/blur1.gdshader.uid b/game/addons/simplegrasstextured/shaders/blur1.gdshader.uid
index 80048a2..2c3b37b 100644
--- a/game/addons/simplegrasstextured/shaders/blur1.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/blur1.gdshader.uid
@@ -1 +1 @@
-uid://dii7jpdyaypc6
+uid://dii7jpdyaypc6
diff --git a/game/addons/simplegrasstextured/shaders/blur2.gdshader.uid b/game/addons/simplegrasstextured/shaders/blur2.gdshader.uid
index fa23b16..c330d92 100644
--- a/game/addons/simplegrasstextured/shaders/blur2.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/blur2.gdshader.uid
@@ -1 +1 @@
-uid://ct0c3akkhiaf
+uid://ct0c3akkhiaf
diff --git a/game/addons/simplegrasstextured/shaders/distance.gdshader.uid b/game/addons/simplegrasstextured/shaders/distance.gdshader.uid
index 1fe89e3..7c5af83 100644
--- a/game/addons/simplegrasstextured/shaders/distance.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/distance.gdshader.uid
@@ -1 +1 @@
-uid://h3cmhcmf6wwy
+uid://h3cmhcmf6wwy
diff --git a/game/addons/simplegrasstextured/shaders/grass.gdshader.uid b/game/addons/simplegrasstextured/shaders/grass.gdshader.uid
index bb58843..e19ed63 100644
--- a/game/addons/simplegrasstextured/shaders/grass.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/grass.gdshader.uid
@@ -1 +1 @@
-uid://bi3o8elbtqoni
+uid://bi3o8elbtqoni
diff --git a/game/addons/simplegrasstextured/shaders/grass.gdshaderinc.uid b/game/addons/simplegrasstextured/shaders/grass.gdshaderinc.uid
index 7a8a4fe..cc9be60 100644
--- a/game/addons/simplegrasstextured/shaders/grass.gdshaderinc.uid
+++ b/game/addons/simplegrasstextured/shaders/grass.gdshaderinc.uid
@@ -1 +1 @@
-uid://cvdyxro7g1dic
+uid://cvdyxro7g1dic
diff --git a/game/addons/simplegrasstextured/shaders/grass_linear.gdshader.uid b/game/addons/simplegrasstextured/shaders/grass_linear.gdshader.uid
index f4e4e22..158d738 100644
--- a/game/addons/simplegrasstextured/shaders/grass_linear.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/grass_linear.gdshader.uid
@@ -1 +1 @@
-uid://bdx4su8bw3dmw
+uid://bdx4su8bw3dmw
diff --git a/game/addons/simplegrasstextured/shaders/grass_nearest.gdshader.uid b/game/addons/simplegrasstextured/shaders/grass_nearest.gdshader.uid
index b874dbf..df43ff0 100644
--- a/game/addons/simplegrasstextured/shaders/grass_nearest.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/grass_nearest.gdshader.uid
@@ -1 +1 @@
-uid://ch7elftpoqayg
+uid://ch7elftpoqayg
diff --git a/game/addons/simplegrasstextured/shaders/grass_nearest_mipmap.gdshader.uid b/game/addons/simplegrasstextured/shaders/grass_nearest_mipmap.gdshader.uid
index 8961ca4..ba580a2 100644
--- a/game/addons/simplegrasstextured/shaders/grass_nearest_mipmap.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/grass_nearest_mipmap.gdshader.uid
@@ -1 +1 @@
-uid://c56hanl0enhx2
+uid://c56hanl0enhx2
diff --git a/game/addons/simplegrasstextured/shaders/grass_unshaded.gdshader.uid b/game/addons/simplegrasstextured/shaders/grass_unshaded.gdshader.uid
index ff72dac..3acab39 100644
--- a/game/addons/simplegrasstextured/shaders/grass_unshaded.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/grass_unshaded.gdshader.uid
@@ -1 +1 @@
-uid://bdgbdmw2hh77q
+uid://bdgbdmw2hh77q
diff --git a/game/addons/simplegrasstextured/shaders/grass_unshaded_linear.gdshader.uid b/game/addons/simplegrasstextured/shaders/grass_unshaded_linear.gdshader.uid
index ee68619..0e13cd0 100644
--- a/game/addons/simplegrasstextured/shaders/grass_unshaded_linear.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/grass_unshaded_linear.gdshader.uid
@@ -1 +1 @@
-uid://c4damqkvtgm4i
+uid://c4damqkvtgm4i
diff --git a/game/addons/simplegrasstextured/shaders/grass_unshaded_nearest.gdshader.uid b/game/addons/simplegrasstextured/shaders/grass_unshaded_nearest.gdshader.uid
index 31176b5..5319ec6 100644
--- a/game/addons/simplegrasstextured/shaders/grass_unshaded_nearest.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/grass_unshaded_nearest.gdshader.uid
@@ -1 +1 @@
-uid://cdooqj4aiumdm
+uid://cdooqj4aiumdm
diff --git a/game/addons/simplegrasstextured/shaders/grass_unshaded_nearest_mipmap.gdshader.uid b/game/addons/simplegrasstextured/shaders/grass_unshaded_nearest_mipmap.gdshader.uid
index ad5a490..6fcb75a 100644
--- a/game/addons/simplegrasstextured/shaders/grass_unshaded_nearest_mipmap.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/grass_unshaded_nearest_mipmap.gdshader.uid
@@ -1 +1 @@
-uid://cuplxuwag3dnn
+uid://cuplxuwag3dnn
diff --git a/game/addons/simplegrasstextured/shaders/motion1.gdshader.uid b/game/addons/simplegrasstextured/shaders/motion1.gdshader.uid
index 4799650..34cf650 100644
--- a/game/addons/simplegrasstextured/shaders/motion1.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/motion1.gdshader.uid
@@ -1 +1 @@
-uid://bpnki8vkbrrer
+uid://bpnki8vkbrrer
diff --git a/game/addons/simplegrasstextured/shaders/motion2.gdshader.uid b/game/addons/simplegrasstextured/shaders/motion2.gdshader.uid
index 84da273..711d431 100644
--- a/game/addons/simplegrasstextured/shaders/motion2.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/motion2.gdshader.uid
@@ -1 +1 @@
-uid://brbm3okyt3jwq
+uid://brbm3okyt3jwq
diff --git a/game/addons/simplegrasstextured/shaders/normal.gdshader.uid b/game/addons/simplegrasstextured/shaders/normal.gdshader.uid
index 3971f19..f7879d2 100644
--- a/game/addons/simplegrasstextured/shaders/normal.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/normal.gdshader.uid
@@ -1 +1 @@
-uid://c4hxrek5kx6bn
+uid://c4hxrek5kx6bn
diff --git a/game/addons/simplegrasstextured/shaders/position.gdshader.uid b/game/addons/simplegrasstextured/shaders/position.gdshader.uid
index 62f062c..4ab8391 100644
--- a/game/addons/simplegrasstextured/shaders/position.gdshader.uid
+++ b/game/addons/simplegrasstextured/shaders/position.gdshader.uid
@@ -1 +1 @@
-uid://brellka4w2k2s
+uid://brellka4w2k2s
diff --git a/game/addons/simplegrasstextured/singleton.gd.uid b/game/addons/simplegrasstextured/singleton.gd.uid
index 4e55ca9..b199d94 100644
--- a/game/addons/simplegrasstextured/singleton.gd.uid
+++ b/game/addons/simplegrasstextured/singleton.gd.uid
@@ -1 +1 @@
-uid://bcnq5eix75v5b
+uid://bcnq5eix75v5b
diff --git a/game/assets/items/iWeapon.gd b/game/assets/items/iWeapon.gd
index aecdb29..ddd5e7b 100644
--- a/game/assets/items/iWeapon.gd
+++ b/game/assets/items/iWeapon.gd
@@ -1,30 +1,30 @@
-# weapon.gd
-extends Resource
-class_name iWeapon
-# This acts as an interface base class.
-
-# --- Common Stats ---
-@export var weapon_name: String = "Unnamed Weapon"
-@export var damage: float = 10.0
-@export var attack_speed: float = 1.0 # attacks per second
-@export var range: float = 1.5 # meters; melee = short, ranged = long
-@export var knockback: float = 0.0
-@export var stamina_cost: float = 5.0
-
-# --- Ranged‑specific Stats ---
-@export var projectile_scene: PackedScene # null for melee
-@export var projectile_speed: float = 20.0
-@export var ammo_type: String = "" # e.g. "arrows", "bullets"
-@export var ammo_per_shot: int = 1
-
-# --- Melee‑specific Stats ---
-@export var swing_arc: float = 90.0 # degrees
-@export var hitbox_size: float = 1.0
-
-# --- Interface Methods ---
-func attack(user):
- push_error("Weapon.attack() not implemented in subclass")
- return null
-
-func can_attack(user) -> bool:
- return true
+# weapon.gd
+extends Resource
+class_name iWeapon
+# This acts as an interface base class.
+
+# --- Common Stats ---
+@export var weapon_name: String = "Unnamed Weapon"
+@export var damage: float = 10.0
+@export var attack_speed: float = 1.0 # attacks per second
+@export var range: float = 1.5 # meters; melee = short, ranged = long
+@export var knockback: float = 0.0
+@export var stamina_cost: float = 5.0
+
+# --- Ranged‑specific Stats ---
+@export var projectile_scene: PackedScene # null for melee
+@export var projectile_speed: float = 20.0
+@export var ammo_type: String = "" # e.g. "arrows", "bullets"
+@export var ammo_per_shot: int = 1
+
+# --- Melee‑specific Stats ---
+@export var swing_arc: float = 90.0 # degrees
+@export var hitbox_size: float = 1.0
+
+# --- Interface Methods ---
+func attack(user):
+ push_error("Weapon.attack() not implemented in subclass")
+ return null
+
+func can_attack(user) -> bool:
+ return true
diff --git a/game/assets/models/AshlingSwarmer.glb b/game/assets/models/AshlingSwarmer.glb
new file mode 100644
index 0000000..c08b94a
--- /dev/null
+++ b/game/assets/models/AshlingSwarmer.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4a83e4c52e116a0b15f0144944765773e0cc8e141e5f8d2db26703c1cf01ef1c
+size 47971388
diff --git a/game/assets/models/AshlingSwarmer.glb.import b/game/assets/models/AshlingSwarmer.glb.import
new file mode 100644
index 0000000..07c17b7
--- /dev/null
+++ b/game/assets/models/AshlingSwarmer.glb.import
@@ -0,0 +1,42 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://bi13nqi11oqhx"
+path="res://.godot/imported/AshlingSwarmer.glb-69896cc28ddf5352ad63aa98f43893d5.scn"
+
+[deps]
+
+source_file="res://assets/models/AshlingSwarmer.glb"
+dest_files=["res://.godot/imported/AshlingSwarmer.glb-69896cc28ddf5352ad63aa98f43893d5.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
diff --git a/game/assets/models/AshlingSwarmer.pre_anim.glb b/game/assets/models/AshlingSwarmer.pre_anim.glb
new file mode 100644
index 0000000..8c3a04e
--- /dev/null
+++ b/game/assets/models/AshlingSwarmer.pre_anim.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c5346c37f623436d833698db9bdc15308b7faa7cbe77b661af078870fe830f9f
+size 67962972
diff --git a/game/assets/models/AshlingSwarmer.pre_anim.glb.import b/game/assets/models/AshlingSwarmer.pre_anim.glb.import
new file mode 100644
index 0000000..28c03ff
--- /dev/null
+++ b/game/assets/models/AshlingSwarmer.pre_anim.glb.import
@@ -0,0 +1,42 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://db8yuf2l8eavk"
+path="res://.godot/imported/AshlingSwarmer.pre_anim.glb-81e6d03699494e78a323ca2a801ae146.scn"
+
+[deps]
+
+source_file="res://assets/models/AshlingSwarmer.pre_anim.glb"
+dest_files=["res://.godot/imported/AshlingSwarmer.pre_anim.glb-81e6d03699494e78a323ca2a801ae146.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
diff --git a/game/assets/models/AshlingSwarmer.pre_rig.glb b/game/assets/models/AshlingSwarmer.pre_rig.glb
new file mode 100644
index 0000000..662445b
--- /dev/null
+++ b/game/assets/models/AshlingSwarmer.pre_rig.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:815035c3b89b6be90d06d52c2b2b8f9aaa4dde02e1d775e26693fa023de92b9a
+size 47970956
diff --git a/game/assets/models/AshlingSwarmer.pre_rig.glb.import b/game/assets/models/AshlingSwarmer.pre_rig.glb.import
new file mode 100644
index 0000000..2a32d71
--- /dev/null
+++ b/game/assets/models/AshlingSwarmer.pre_rig.glb.import
@@ -0,0 +1,42 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://b3jmkpa8mjqo3"
+path="res://.godot/imported/AshlingSwarmer.pre_rig.glb-f99517ddbdeb482c37550a37bcb3352f.scn"
+
+[deps]
+
+source_file="res://assets/models/AshlingSwarmer.pre_rig.glb"
+dest_files=["res://.godot/imported/AshlingSwarmer.pre_rig.glb-f99517ddbdeb482c37550a37bcb3352f.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
diff --git a/game/assets/models/AshlingSwarmer.previous.glb b/game/assets/models/AshlingSwarmer.previous.glb
new file mode 100644
index 0000000..c1ef0a6
--- /dev/null
+++ b/game/assets/models/AshlingSwarmer.previous.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2edbd8c9a825473e083cf29f26979ff0fd48afefc4ee6e3e60d1f533cff6e96d
+size 648660
diff --git a/game/assets/models/AshlingSwarmer.previous.glb.import b/game/assets/models/AshlingSwarmer.previous.glb.import
new file mode 100644
index 0000000..fa0ccb3
--- /dev/null
+++ b/game/assets/models/AshlingSwarmer.previous.glb.import
@@ -0,0 +1,42 @@
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://gd2yk5hi4u30"
+path="res://.godot/imported/AshlingSwarmer.previous.glb-13f621ed6557d1df82d0fc05a90344cf.scn"
+
+[deps]
+
+source_file="res://assets/models/AshlingSwarmer.previous.glb"
+dest_files=["res://.godot/imported/AshlingSwarmer.previous.glb-13f621ed6557d1df82d0fc05a90344cf.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
diff --git a/game/assets/models/TestCharAnimated.glb b/game/assets/models/TestCharAnimated.glb
index ed97db1..3e1bdab 100644
Binary files a/game/assets/models/TestCharAnimated.glb and b/game/assets/models/TestCharAnimated.glb differ
diff --git a/game/assets/models/humanFemale.glb b/game/assets/models/humanFemale.glb
index 78b63d3..49dc09d 100644
Binary files a/game/assets/models/humanFemale.glb and b/game/assets/models/humanFemale.glb differ
diff --git a/game/assets/test.tscn b/game/assets/test.tscn
index 0edf9e8..672aa1a 100644
--- a/game/assets/test.tscn
+++ b/game/assets/test.tscn
@@ -1,3 +1,3 @@
-[gd_scene format=3 uid="uid://cuyws13lbkmxb"]
-
-[node name="Test" type="Node2D"]
+[gd_scene format=3 uid="uid://cuyws13lbkmxb"]
+
+[node name="Test" type="Node2D"]
diff --git a/game/assets/textures/ashling_swarmer/ashling_swarmer_palette.png b/game/assets/textures/ashling_swarmer/ashling_swarmer_palette.png
new file mode 100644
index 0000000..d5f5bc2
Binary files /dev/null and b/game/assets/textures/ashling_swarmer/ashling_swarmer_palette.png differ
diff --git a/game/assets/textures/ashling_swarmer/ashling_swarmer_palette.png.import b/game/assets/textures/ashling_swarmer/ashling_swarmer_palette.png.import
new file mode 100644
index 0000000..24d2353
--- /dev/null
+++ b/game/assets/textures/ashling_swarmer/ashling_swarmer_palette.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://bpcj2xno5aykg"
+path="res://.godot/imported/ashling_swarmer_palette.png-c771be7c592755dce8c857fa3b76a603.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/textures/ashling_swarmer/ashling_swarmer_palette.png"
+dest_files=["res://.godot/imported/ashling_swarmer_palette.png-c771be7c592755dce8c857fa3b76a603.ctex"]
+
+[params]
+
+compress/mode=0
+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=false
+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=1
diff --git a/game/audio/bus_layout.tres b/game/audio/bus_layout.tres
index 7481000..03b66b7 100644
--- a/game/audio/bus_layout.tres
+++ b/game/audio/bus_layout.tres
@@ -1,12 +1,12 @@
-[gd_resource type="AudioBusLayout" format=3]
-
-[resource]
-bus/0/name = "Master"
-bus/0/volume_db = 0.0
-bus/0/send = ""
-bus/1/name = "Music"
-bus/1/volume_db = 0.0
-bus/1/send = "Master"
-bus/2/name = "SFX"
-bus/2/volume_db = 0.0
-bus/2/send = "Master"
+[gd_resource type="AudioBusLayout" format=3]
+
+[resource]
+bus/0/name = "Master"
+bus/0/volume_db = 0.0
+bus/0/send = ""
+bus/1/name = "Music"
+bus/1/volume_db = 0.0
+bus/1/send = "Master"
+bus/2/name = "SFX"
+bus/2/volume_db = 0.0
+bus/2/send = "Master"
diff --git a/game/project.godot b/game/project.godot
index 1de6628..b014e5e 100644
--- a/game/project.godot
+++ b/game/project.godot
@@ -56,6 +56,7 @@ CharacterService="*res://scenes/UI/character_service.gd"
SelectedCharacter="*res://scenes/UI/selected_character.gd"
DialogSystem="*res://scenes/UI/dialog_system.gd"
QuestManager="*res://scenes/Quests/quest_manager.gd"
+TeleportState="*res://scenes/Interaction/teleport_state.gd"
SimpleGrass="*res://addons/simplegrasstextured/singleton.tscn"
[dotnet]
@@ -64,7 +65,7 @@ project/assembly_name="Promiscuity"
[editor_plugins]
-enabled=PackedStringArray("res://addons/simplegrasstextured/plugin.cfg")
+enabled=PackedStringArray("res://addons/godot_mcp/plugin.cfg", "res://addons/simplegrasstextured/plugin.cfg")
[input]
diff --git a/game/scenes/Characters/ashling_swarmer.gd b/game/scenes/Characters/ashling_swarmer.gd
new file mode 100644
index 0000000..0ceb99c
--- /dev/null
+++ b/game/scenes/Characters/ashling_swarmer.gd
@@ -0,0 +1,110 @@
+extends Node3D
+
+@export var model_path: NodePath = NodePath("AshlingSwarmerModel")
+@export var default_animation: StringName = &"Idle"
+@export var autoplay: bool = true
+
+var _animation_player: AnimationPlayer
+var _model_node: Node3D
+var _current_animation: StringName = &""
+
+
+func _ready() -> void:
+ call_deferred("_setup_animation")
+
+
+func _setup_animation() -> void:
+ _model_node = get_node_or_null(model_path) as Node3D
+ _animation_player = _find_animation_player(_model_node)
+ if _animation_player == null:
+ if autoplay:
+ push_warning("AshlingSwarmer: no AnimationPlayer found under %s." % model_path)
+ return
+ if autoplay:
+ play_animation(default_animation)
+
+
+func play_animation(animation_name: StringName = &"") -> void:
+ if _animation_player == null:
+ _animation_player = _find_animation_player(get_node_or_null(model_path))
+ if _animation_player == null:
+ return
+ var requested_animation := animation_name if animation_name != StringName() else default_animation
+ var resolved_animation := _resolve_animation_name(requested_animation)
+ if resolved_animation == StringName():
+ push_warning("AshlingSwarmer: animation '%s' not found. Available animations: %s" % [requested_animation, _animation_player.get_animation_list()])
+ return
+ var animation := _animation_player.get_animation(resolved_animation)
+ if animation != null:
+ animation.loop_mode = Animation.LOOP_LINEAR
+ _animation_player.play(resolved_animation)
+ _current_animation = _classify_animation(resolved_animation)
+
+
+func play_idle() -> void:
+ play_animation(&"Idle")
+
+
+func play_run() -> void:
+ play_animation(&"Run")
+
+
+func play_attack() -> void:
+ play_animation(&"Attack_Leap")
+
+
+func play_hit() -> void:
+ play_animation(&"Hit")
+
+
+func play_death() -> void:
+ play_animation(&"Death_Explode")
+
+
+func _resolve_animation_name(animation_name: StringName) -> StringName:
+ if _animation_player.has_animation(animation_name):
+ return animation_name
+ var desired := String(animation_name).to_lower()
+ for candidate in _animation_player.get_animation_list():
+ var candidate_text := String(candidate)
+ if candidate_text.to_lower() == desired:
+ return StringName(candidate)
+ for candidate in _animation_player.get_animation_list():
+ var candidate_text := String(candidate)
+ if candidate_text.to_lower().contains(desired):
+ return StringName(candidate)
+ for candidate in _animation_player.get_animation_list():
+ var candidate_text := String(candidate)
+ if candidate_text.to_lower().contains("idle"):
+ return StringName(candidate)
+ var animations := _animation_player.get_animation_list()
+ if animations.size() > 0:
+ return StringName(animations[0])
+ return StringName()
+
+
+func _classify_animation(animation_name: StringName) -> StringName:
+ var text := String(animation_name).to_lower()
+ if text.contains("run"):
+ return &"Run"
+ if text.contains("idle"):
+ return &"Idle"
+ if text.contains("attack"):
+ return &"Attack_Leap"
+ if text.contains("hit"):
+ return &"Hit"
+ if text.contains("death"):
+ return &"Death_Explode"
+ return animation_name
+
+
+func _find_animation_player(root: Node) -> AnimationPlayer:
+ if root == null:
+ return null
+ if root is AnimationPlayer:
+ return root
+ for child in root.get_children():
+ var found := _find_animation_player(child)
+ if found != null:
+ return found
+ return null
diff --git a/game/scenes/Characters/ashling_swarmer.gd.uid b/game/scenes/Characters/ashling_swarmer.gd.uid
new file mode 100644
index 0000000..ac1daf4
--- /dev/null
+++ b/game/scenes/Characters/ashling_swarmer.gd.uid
@@ -0,0 +1 @@
+uid://dptxcarifu7e1
diff --git a/game/scenes/Characters/ashling_swarmer.tscn b/game/scenes/Characters/ashling_swarmer.tscn
new file mode 100644
index 0000000..8e56c3c
--- /dev/null
+++ b/game/scenes/Characters/ashling_swarmer.tscn
@@ -0,0 +1,36 @@
+[gd_scene load_steps=5 format=3]
+
+[ext_resource type="Script" path="res://scenes/Characters/ashling_swarmer.gd" id="1_script"]
+[ext_resource type="PackedScene" path="res://assets/models/AshlingSwarmer.glb" id="2_model"]
+[ext_resource type="Script" path="res://scenes/Interaction/dialog_trigger_area.gd" id="3_dialog"]
+
+[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
+radius = 0.45
+height = 1.6
+
+[sub_resource type="SphereShape3D" id="SphereShape3D_interact"]
+radius = 1.8
+
+[node name="AshlingSwarmer" type="Node3D"]
+script = ExtResource("1_script")
+autoplay = false
+
+[node name="Body" type="StaticBody3D" parent="."]
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8, 0)
+shape = SubResource("CapsuleShape3D_body")
+
+[node name="AshlingSwarmerModel" parent="." instance=ExtResource("2_model")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
+
+[node name="InteractArea" type="Area3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8, 0)
+script = ExtResource("3_dialog")
+collision_layer = 2
+collision_mask = 1
+prompt_text = "Press E to inspect"
+dialog_text = "Ashling Swarmer: A small ember-born creature watches you."
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="InteractArea"]
+shape = SubResource("SphereShape3D_interact")
diff --git a/game/scenes/Characters/repo_bot.gd b/game/scenes/Characters/repo_bot.gd
index 0af1a4f..edfdae2 100644
--- a/game/scenes/Characters/repo_bot.gd
+++ b/game/scenes/Characters/repo_bot.gd
@@ -19,15 +19,15 @@ extends Node3D
@export var vertical_unlock_height: float = 0.6
@export var vertical_lock_smooth_speed: float = 6.0
@export var vertical_lock_hold_time: float = 0.3
-@export var max_look_angle_deg: float = 90.0
-@export var eye_return_speed: float = 0.2
-@export var max_offset: float = 0.08
-@export var side_eye_boost: float = 1.4
-@export var head_path: NodePath = NodePath("Body/HeadPivot")
-@export var head_turn_speed: float = 16.0
-@export var head_max_yaw_deg: float = 55.0
-@export var head_max_pitch_deg: float = 22.0
-
+@export var max_look_angle_deg: float = 90.0
+@export var eye_return_speed: float = 0.2
+@export var max_offset: float = 0.08
+@export var side_eye_boost: float = 1.4
+@export var head_path: NodePath = NodePath("Body/HeadPivot")
+@export var head_turn_speed: float = 16.0
+@export var head_max_yaw_deg: float = 55.0
+@export var head_max_pitch_deg: float = 22.0
+
var _left_pupil: Node3D
var _right_pupil: Node3D
var _body: RigidBody3D
@@ -35,9 +35,9 @@ var _left_base: Vector3
var _right_base: Vector3
var _camera: Camera3D
var _look_origin: Node3D
-var _head: Node3D
-var _head_base_rot: Vector3
-var _vertical_lock_factor: float = 1.0
+var _head: Node3D
+var _head_base_rot: Vector3
+var _vertical_lock_factor: float = 1.0
var _vertical_hold_timer: float = 0.0
var _look_reference: Node3D
var _look_target: Node3D
@@ -50,9 +50,9 @@ func _ready() -> void:
_right_pupil = get_node_or_null(right_pupil_path) as Node3D
if _left_pupil:
_left_base = _left_pupil.position
- if _right_pupil:
- _right_base = _right_pupil.position
- _camera = _resolve_camera()
+ if _right_pupil:
+ _right_base = _right_pupil.position
+ _camera = _resolve_camera()
_look_origin = get_node_or_null(look_origin_path) as Node3D
_look_reference = get_node_or_null(look_reference_path) as Node3D
_look_target = get_node_or_null(look_target_path) as Node3D
@@ -76,8 +76,8 @@ func _physics_process(delta: float) -> void:
func _process(_delta: float) -> void:
_sync_body_to_root()
_update_pupils()
-
-
+
+
func _update_pupils() -> void:
if _look_target == null and look_target_path != NodePath(""):
_look_target = get_node_or_null(look_target_path) as Node3D
@@ -93,56 +93,56 @@ func _update_pupils() -> void:
if origin == null:
origin = self
var target := _look_target.global_position if _look_target != null else _camera.global_position
- var dir_world := target - origin.global_position
- if dir_world.length_squared() <= 0.0001:
- return
- dir_world = dir_world.normalized()
- var reference := _look_reference if _look_reference != null else origin
- var forward := -reference.global_basis.z
- var min_dot := cos(deg_to_rad(max_look_angle_deg))
- var can_look := dir_world.dot(forward) >= min_dot
- if not can_look:
- var delta := get_process_delta_time()
- if _left_pupil:
- var target_left := Vector3(_left_base.x, _left_base.y, _left_base.z)
- var pos_left := _left_pupil.position
- pos_left.x = move_toward(pos_left.x, target_left.x, eye_return_speed * delta)
- pos_left.y = target_left.y
- pos_left.z = move_toward(pos_left.z, target_left.z, eye_return_speed * delta)
- _left_pupil.position = pos_left
- if _right_pupil:
- var target_right := Vector3(_right_base.x, _right_base.y, _right_base.z)
- var pos_right := _right_pupil.position
- pos_right.x = move_toward(pos_right.x, target_right.x, eye_return_speed * delta)
- pos_right.y = target_right.y
- pos_right.z = move_toward(pos_right.z, target_right.z, eye_return_speed * delta)
- _right_pupil.position = pos_right
- if _head:
- _head.rotation.x = _head_base_rot.x
- _head.rotation.y = lerp_angle(_head.rotation.y, _head_base_rot.y, head_turn_speed * delta)
- return
- if lock_vertical:
- var origin_y := origin.global_position.y
- var target_offset := target.y - origin_y
- var is_above := target_offset > vertical_unlock_height
- if is_above:
- _vertical_hold_timer = vertical_lock_hold_time
- else:
- _vertical_hold_timer = max(0.0, _vertical_hold_timer - get_process_delta_time())
- if is_above:
- _vertical_lock_factor = 1.0
- else:
- var unlock := 1.0 if _vertical_hold_timer > 0.0 else 0.0
- _vertical_lock_factor = move_toward(_vertical_lock_factor, unlock, vertical_lock_smooth_speed * get_process_delta_time())
- target.y = lerp(origin_y, target.y, _vertical_lock_factor)
- dir_world = target - origin.global_position
- if dir_world.length_squared() <= 0.0001:
- return
- dir_world = dir_world.normalized()
- if _left_pupil:
- _update_eye(_left_pupil, _left_base, dir_world)
- if _right_pupil:
- _update_eye(_right_pupil, _right_base, dir_world)
+ var dir_world := target - origin.global_position
+ if dir_world.length_squared() <= 0.0001:
+ return
+ dir_world = dir_world.normalized()
+ var reference := _look_reference if _look_reference != null else origin
+ var forward := -reference.global_basis.z
+ var min_dot := cos(deg_to_rad(max_look_angle_deg))
+ var can_look := dir_world.dot(forward) >= min_dot
+ if not can_look:
+ var delta := get_process_delta_time()
+ if _left_pupil:
+ var target_left := Vector3(_left_base.x, _left_base.y, _left_base.z)
+ var pos_left := _left_pupil.position
+ pos_left.x = move_toward(pos_left.x, target_left.x, eye_return_speed * delta)
+ pos_left.y = target_left.y
+ pos_left.z = move_toward(pos_left.z, target_left.z, eye_return_speed * delta)
+ _left_pupil.position = pos_left
+ if _right_pupil:
+ var target_right := Vector3(_right_base.x, _right_base.y, _right_base.z)
+ var pos_right := _right_pupil.position
+ pos_right.x = move_toward(pos_right.x, target_right.x, eye_return_speed * delta)
+ pos_right.y = target_right.y
+ pos_right.z = move_toward(pos_right.z, target_right.z, eye_return_speed * delta)
+ _right_pupil.position = pos_right
+ if _head:
+ _head.rotation.x = _head_base_rot.x
+ _head.rotation.y = lerp_angle(_head.rotation.y, _head_base_rot.y, head_turn_speed * delta)
+ return
+ if lock_vertical:
+ var origin_y := origin.global_position.y
+ var target_offset := target.y - origin_y
+ var is_above := target_offset > vertical_unlock_height
+ if is_above:
+ _vertical_hold_timer = vertical_lock_hold_time
+ else:
+ _vertical_hold_timer = max(0.0, _vertical_hold_timer - get_process_delta_time())
+ if is_above:
+ _vertical_lock_factor = 1.0
+ else:
+ var unlock := 1.0 if _vertical_hold_timer > 0.0 else 0.0
+ _vertical_lock_factor = move_toward(_vertical_lock_factor, unlock, vertical_lock_smooth_speed * get_process_delta_time())
+ target.y = lerp(origin_y, target.y, _vertical_lock_factor)
+ dir_world = target - origin.global_position
+ if dir_world.length_squared() <= 0.0001:
+ return
+ dir_world = dir_world.normalized()
+ if _left_pupil:
+ _update_eye(_left_pupil, _left_base, dir_world)
+ if _right_pupil:
+ _update_eye(_right_pupil, _right_base, dir_world)
if _head:
_update_head(dir_world)
@@ -247,39 +247,39 @@ func _resolve_active_camera() -> Camera3D:
func _resolve_camera() -> Camera3D:
- if camera_path != NodePath(""):
- var from_path := get_node_or_null(camera_path) as Camera3D
- if from_path:
- return from_path
- var viewport_cam := get_viewport().get_camera_3d()
- if viewport_cam:
- return viewport_cam
- var by_name := get_tree().get_root().find_child("Camera3D", true, false) as Camera3D
- return by_name
-
-
-func _update_eye(eye: Node3D, base_pos: Vector3, dir_world: Vector3) -> void:
- var parent := eye.get_parent() as Node3D
- if parent == null:
- return
- var dir_local := parent.global_basis.inverse() * dir_world
- var flat := Vector2(dir_local.x, dir_local.y)
- flat.x *= side_eye_boost
- if flat.length() > 1.0:
- flat = flat.normalized()
- var offset := Vector3(flat.x, flat.y, 0.0) * max_offset
- eye.position = base_pos + offset
-
-
-func _update_head(dir_world: Vector3) -> void:
- var parent := _head.get_parent() as Node3D
- if parent == null:
- return
- var dir_local := parent.global_basis.inverse() * dir_world
- var yaw := atan2(-dir_local.x, -dir_local.z)
- var pitch := atan2(dir_local.y, -dir_local.z)
- yaw = clamp(yaw, deg_to_rad(-head_max_yaw_deg), deg_to_rad(head_max_yaw_deg))
- pitch = clamp(pitch, deg_to_rad(-head_max_pitch_deg), deg_to_rad(head_max_pitch_deg))
- var target := Vector3(_head_base_rot.x + pitch, _head_base_rot.y + yaw, _head_base_rot.z)
- _head.rotation.x = lerp_angle(_head.rotation.x, target.x, head_turn_speed * get_process_delta_time())
- _head.rotation.y = lerp_angle(_head.rotation.y, target.y, head_turn_speed * get_process_delta_time())
+ if camera_path != NodePath(""):
+ var from_path := get_node_or_null(camera_path) as Camera3D
+ if from_path:
+ return from_path
+ var viewport_cam := get_viewport().get_camera_3d()
+ if viewport_cam:
+ return viewport_cam
+ var by_name := get_tree().get_root().find_child("Camera3D", true, false) as Camera3D
+ return by_name
+
+
+func _update_eye(eye: Node3D, base_pos: Vector3, dir_world: Vector3) -> void:
+ var parent := eye.get_parent() as Node3D
+ if parent == null:
+ return
+ var dir_local := parent.global_basis.inverse() * dir_world
+ var flat := Vector2(dir_local.x, dir_local.y)
+ flat.x *= side_eye_boost
+ if flat.length() > 1.0:
+ flat = flat.normalized()
+ var offset := Vector3(flat.x, flat.y, 0.0) * max_offset
+ eye.position = base_pos + offset
+
+
+func _update_head(dir_world: Vector3) -> void:
+ var parent := _head.get_parent() as Node3D
+ if parent == null:
+ return
+ var dir_local := parent.global_basis.inverse() * dir_world
+ var yaw := atan2(-dir_local.x, -dir_local.z)
+ var pitch := atan2(dir_local.y, -dir_local.z)
+ yaw = clamp(yaw, deg_to_rad(-head_max_yaw_deg), deg_to_rad(head_max_yaw_deg))
+ pitch = clamp(pitch, deg_to_rad(-head_max_pitch_deg), deg_to_rad(head_max_pitch_deg))
+ var target := Vector3(_head_base_rot.x + pitch, _head_base_rot.y + yaw, _head_base_rot.z)
+ _head.rotation.x = lerp_angle(_head.rotation.x, target.x, head_turn_speed * get_process_delta_time())
+ _head.rotation.y = lerp_angle(_head.rotation.y, target.y, head_turn_speed * get_process_delta_time())
diff --git a/game/scenes/Characters/repo_bot.tscn b/game/scenes/Characters/repo_bot.tscn
index 116173f..e8ea391 100644
--- a/game/scenes/Characters/repo_bot.tscn
+++ b/game/scenes/Characters/repo_bot.tscn
@@ -2,119 +2,119 @@
[ext_resource type="Script" path="res://scenes/Characters/repo_bot.gd" id="1_repo_bot"]
[ext_resource type="Script" path="res://scenes/Interaction/dialog_trigger_area.gd" id="2_dialog"]
-
-[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_body"]
-albedo_color = Color(0.78, 0.8, 0.82, 1)
-metallic = 0.2
-roughness = 0.35
-
-[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_accent"]
-albedo_color = Color(0.25, 0.82, 0.55, 1)
-emission_enabled = true
-emission = Color(0.25, 0.82, 0.55, 1)
-
-[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_eye_white"]
-albedo_color = Color(0.95, 0.95, 0.95, 1)
-roughness = 0.2
-
-[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pupil"]
-albedo_color = Color(0.02, 0.02, 0.02, 1)
-roughness = 0.8
-
-[sub_resource type="CapsuleMesh" id="CapsuleMesh_body"]
-radius = 0.25
-height = 0.6
-material = SubResource("StandardMaterial3D_body")
-
-[sub_resource type="SphereMesh" id="SphereMesh_head"]
-radius = 0.22
-height = 0.44
-material = SubResource("StandardMaterial3D_body")
-
-[sub_resource type="SphereMesh" id="SphereMesh_eye_white"]
-radius = 0.075
-height = 0.15
-material = SubResource("StandardMaterial3D_eye_white")
-
-[sub_resource type="SphereMesh" id="SphereMesh_pupil"]
-radius = 0.028
-height = 0.056
-material = SubResource("StandardMaterial3D_pupil")
-
-[sub_resource type="CylinderMesh" id="CylinderMesh_limb"]
-top_radius = 0.06
-bottom_radius = 0.06
-height = 0.35
-material = SubResource("StandardMaterial3D_body")
-
-[sub_resource type="BoxMesh" id="BoxMesh_pack"]
-size = Vector3(0.26, 0.3, 0.12)
-material = SubResource("StandardMaterial3D_accent")
-
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_body"]
+albedo_color = Color(0.78, 0.8, 0.82, 1)
+metallic = 0.2
+roughness = 0.35
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_accent"]
+albedo_color = Color(0.25, 0.82, 0.55, 1)
+emission_enabled = true
+emission = Color(0.25, 0.82, 0.55, 1)
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_eye_white"]
+albedo_color = Color(0.95, 0.95, 0.95, 1)
+roughness = 0.2
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_pupil"]
+albedo_color = Color(0.02, 0.02, 0.02, 1)
+roughness = 0.8
+
+[sub_resource type="CapsuleMesh" id="CapsuleMesh_body"]
+radius = 0.25
+height = 0.6
+material = SubResource("StandardMaterial3D_body")
+
+[sub_resource type="SphereMesh" id="SphereMesh_head"]
+radius = 0.22
+height = 0.44
+material = SubResource("StandardMaterial3D_body")
+
+[sub_resource type="SphereMesh" id="SphereMesh_eye_white"]
+radius = 0.075
+height = 0.15
+material = SubResource("StandardMaterial3D_eye_white")
+
+[sub_resource type="SphereMesh" id="SphereMesh_pupil"]
+radius = 0.028
+height = 0.056
+material = SubResource("StandardMaterial3D_pupil")
+
+[sub_resource type="CylinderMesh" id="CylinderMesh_limb"]
+top_radius = 0.06
+bottom_radius = 0.06
+height = 0.35
+material = SubResource("StandardMaterial3D_body")
+
+[sub_resource type="BoxMesh" id="BoxMesh_pack"]
+size = Vector3(0.26, 0.3, 0.12)
+material = SubResource("StandardMaterial3D_accent")
+
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 0.3
height = 1.1
[sub_resource type="SphereShape3D" id="SphereShape3D_interact"]
radius = 1.7
-
-[node name="RepoBot" type="Node3D"]
-script = ExtResource("1_repo_bot")
-
+
+[node name="RepoBot" type="Node3D"]
+script = ExtResource("1_repo_bot")
+
[node name="Body" type="RigidBody3D" parent="."]
mass = 1.5
axis_lock_angular_x = true
axis_lock_angular_z = true
angular_damp = 8.0
linear_damp = 0.5
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="Body"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.55, 0)
-shape = SubResource("CapsuleShape3D_body")
-
-[node name="Torso" type="MeshInstance3D" parent="Body"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
-mesh = SubResource("CapsuleMesh_body")
-
-[node name="HeadPivot" type="Node3D" parent="Body"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.95, 0)
-
-[node name="Head" type="MeshInstance3D" parent="Body/HeadPivot"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
-mesh = SubResource("SphereMesh_head")
-
-[node name="EyeLeft" type="MeshInstance3D" parent="Body/HeadPivot"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.09, 0, -0.205)
-mesh = SubResource("SphereMesh_eye_white")
-
-[node name="Pupil" type="MeshInstance3D" parent="Body/HeadPivot/EyeLeft"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.06)
-mesh = SubResource("SphereMesh_pupil")
-
-[node name="EyeRight" type="MeshInstance3D" parent="Body/HeadPivot"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.09, 0, -0.205)
-mesh = SubResource("SphereMesh_eye_white")
-
-[node name="Pupil" type="MeshInstance3D" parent="Body/HeadPivot/EyeRight"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.06)
-mesh = SubResource("SphereMesh_pupil")
-
-[node name="ArmLeft" type="MeshInstance3D" parent="Body"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.32, 0.55, 0)
-mesh = SubResource("CylinderMesh_limb")
-
-[node name="ArmRight" type="MeshInstance3D" parent="Body"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.32, 0.55, 0)
-mesh = SubResource("CylinderMesh_limb")
-
-[node name="LegLeft" type="MeshInstance3D" parent="Body"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.12, 0.15, 0)
-mesh = SubResource("CylinderMesh_limb")
-
-[node name="LegRight" type="MeshInstance3D" parent="Body"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.12, 0.15, 0)
-mesh = SubResource("CylinderMesh_limb")
-
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.55, 0)
+shape = SubResource("CapsuleShape3D_body")
+
+[node name="Torso" type="MeshInstance3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
+mesh = SubResource("CapsuleMesh_body")
+
+[node name="HeadPivot" type="Node3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.95, 0)
+
+[node name="Head" type="MeshInstance3D" parent="Body/HeadPivot"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
+mesh = SubResource("SphereMesh_head")
+
+[node name="EyeLeft" type="MeshInstance3D" parent="Body/HeadPivot"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.09, 0, -0.205)
+mesh = SubResource("SphereMesh_eye_white")
+
+[node name="Pupil" type="MeshInstance3D" parent="Body/HeadPivot/EyeLeft"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.06)
+mesh = SubResource("SphereMesh_pupil")
+
+[node name="EyeRight" type="MeshInstance3D" parent="Body/HeadPivot"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.09, 0, -0.205)
+mesh = SubResource("SphereMesh_eye_white")
+
+[node name="Pupil" type="MeshInstance3D" parent="Body/HeadPivot/EyeRight"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -0.06)
+mesh = SubResource("SphereMesh_pupil")
+
+[node name="ArmLeft" type="MeshInstance3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.32, 0.55, 0)
+mesh = SubResource("CylinderMesh_limb")
+
+[node name="ArmRight" type="MeshInstance3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.32, 0.55, 0)
+mesh = SubResource("CylinderMesh_limb")
+
+[node name="LegLeft" type="MeshInstance3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.12, 0.15, 0)
+mesh = SubResource("CylinderMesh_limb")
+
+[node name="LegRight" type="MeshInstance3D" parent="Body"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.12, 0.15, 0)
+mesh = SubResource("CylinderMesh_limb")
+
[node name="Backpack" type="MeshInstance3D" parent="Body"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0.22)
mesh = SubResource("BoxMesh_pack")
diff --git a/game/scenes/Interaction/RadialCommandMenu.gd b/game/scenes/Interaction/RadialCommandMenu.gd
index ae2b0ac..ef4f2e6 100644
--- a/game/scenes/Interaction/RadialCommandMenu.gd
+++ b/game/scenes/Interaction/RadialCommandMenu.gd
@@ -21,8 +21,10 @@ func _toggle_menu():
_animate_menu(false)
func _arrange_buttons():
- var buttons = get_children()
+ var buttons = get_children().filter(func(child): return child is Control)
var count = buttons.size()
+ if count == 0:
+ return
for i in range(count):
# Calculate the angle for each button in radians
@@ -36,7 +38,7 @@ func _arrange_buttons():
func _animate_menu(opening: bool):
var tween = create_tween().set_parallel(true)
- for button in get_children():
+ for button in get_children().filter(func(child): return child is Control):
var final_scale = Vector2.ONE if opening else Vector2.ZERO
tween.tween_property(button, "scale", final_scale, transition_speed).from(Vector2.ZERO if opening else Vector2.ONE)
diff --git a/game/scenes/Interaction/prototype_gateway.gd b/game/scenes/Interaction/prototype_gateway.gd
index c9fb601..2a4b698 100644
--- a/game/scenes/Interaction/prototype_gateway.gd
+++ b/game/scenes/Interaction/prototype_gateway.gd
@@ -16,6 +16,21 @@ extends Node3D
one_shot = value
_sync_teleporter()
+@export var target_spawn_name: StringName = &"":
+ set(value):
+ target_spawn_name = value
+ _sync_teleporter()
+
+@export var quest_event_name: StringName = &"":
+ set(value):
+ quest_event_name = value
+ _sync_teleporter()
+
+@export var quest_id_filter: String = "":
+ set(value):
+ quest_id_filter = value
+ _sync_teleporter()
+
@onready var teleporter: Area3D = $Teleporter
@@ -29,3 +44,6 @@ func _sync_teleporter() -> void:
teleporter.set("target_scene_path", target_scene_path)
teleporter.set("target_group", target_group)
teleporter.set("one_shot", one_shot)
+ teleporter.set("target_spawn_name", target_spawn_name)
+ teleporter.set("quest_event_name", quest_event_name)
+ teleporter.set("quest_id_filter", quest_id_filter)
diff --git a/game/scenes/Interaction/scene_teleporter.gd b/game/scenes/Interaction/scene_teleporter.gd
index 692d43b..079fb80 100644
--- a/game/scenes/Interaction/scene_teleporter.gd
+++ b/game/scenes/Interaction/scene_teleporter.gd
@@ -3,6 +3,9 @@ extends Area3D
@export_file("*.tscn") var target_scene_path := "res://scenes/Levels/transportation_level.tscn"
@export var target_group: StringName = &"player"
@export var one_shot := true
+@export var target_spawn_name: StringName = &""
+@export var quest_event_name: StringName = &""
+@export var quest_id_filter: String = ""
var _is_transitioning := false
@@ -22,6 +25,9 @@ func _on_body_entered(body: Node) -> void:
if not ResourceLoader.exists(target_scene_path):
push_warning("Teleporter target scene does not exist: %s" % target_scene_path)
return
+ _emit_quest_event(body)
+ if TeleportState != null:
+ TeleportState.request_spawn(target_spawn_name)
_is_transitioning = true
if one_shot:
@@ -29,6 +35,17 @@ func _on_body_entered(body: Node) -> void:
call_deferred("_deferred_change_scene")
+func _emit_quest_event(body: Node) -> void:
+ if quest_event_name == StringName() or QuestManager == null:
+ return
+ if quest_id_filter.strip_edges() != "" and QuestManager.get_active_quest_id() != StringName(quest_id_filter):
+ return
+ QuestManager.emit_event(quest_event_name, {
+ "body": body,
+ "source": self,
+ })
+
+
func _deferred_change_scene() -> void:
var err := get_tree().change_scene_to_file(target_scene_path)
if err == OK:
diff --git a/game/scenes/Interaction/teleport_state.gd b/game/scenes/Interaction/teleport_state.gd
new file mode 100644
index 0000000..e3206df
--- /dev/null
+++ b/game/scenes/Interaction/teleport_state.gd
@@ -0,0 +1,13 @@
+extends Node
+
+var _requested_spawn_name: StringName = &""
+
+
+func request_spawn(spawn_name: StringName) -> void:
+ _requested_spawn_name = spawn_name
+
+
+func consume_spawn_name() -> StringName:
+ var spawn_name := _requested_spawn_name
+ _requested_spawn_name = &""
+ return spawn_name
diff --git a/game/scenes/Interaction/teleport_state.gd.uid b/game/scenes/Interaction/teleport_state.gd.uid
new file mode 100644
index 0000000..617fe16
--- /dev/null
+++ b/game/scenes/Interaction/teleport_state.gd.uid
@@ -0,0 +1 @@
+uid://ds35gqf0obsxf
diff --git a/game/scenes/Levels/RadialCommandMenu.gd b/game/scenes/Levels/RadialCommandMenu.gd
index 8466fe0..8f9bdcd 100644
--- a/game/scenes/Levels/RadialCommandMenu.gd
+++ b/game/scenes/Levels/RadialCommandMenu.gd
@@ -21,8 +21,10 @@ func _toggle_menu():
_animate_menu(false)
func _arrange_buttons():
- var buttons = get_children()
+ var buttons = get_children().filter(func(child): return child is Control)
var count = buttons.size()
+ if count == 0:
+ return
for i in range(count):
# Calculate the angle for each button in radians
@@ -36,6 +38,6 @@ func _arrange_buttons():
func _animate_menu(opening: bool):
var tween = create_tween().set_parallel(true)
- for button in get_children():
+ for button in get_children().filter(func(child): return child is Control):
var final_scale = Vector2.ONE if opening else Vector2.ZERO
tween.tween_property(button, "scale", final_scale, transition_speed).from(Vector2.ZERO if opening else Vector2.ONE)
diff --git a/game/scenes/Levels/ashling_level.gd b/game/scenes/Levels/ashling_level.gd
new file mode 100644
index 0000000..5ccad12
--- /dev/null
+++ b/game/scenes/Levels/ashling_level.gd
@@ -0,0 +1,77 @@
+extends Node3D
+
+@export var player_spawn_position := Vector3(0.0, 0.0, 0.0)
+@export var day_length := 120.0
+@export var start_light_angle := -90.0
+
+@onready var _player: RigidBody3D = get_node_or_null("Player") as RigidBody3D
+@onready var _sun: DirectionalLight3D = $DirectionalLight3D
+@onready var _quest_text: RichTextLabel = get_node_or_null("PhoneUI/Control/PhoneFrame/QuestText") as RichTextLabel
+
+var _time := 0.0
+
+
+func _ready() -> void:
+ _move_player_to_spawn()
+ _setup_quest_ui()
+
+
+func _process(delta: float) -> void:
+ _update_day_night(delta)
+
+
+func _move_player_to_spawn() -> void:
+ if _player == null:
+ return
+ var spawn_marker := _consume_spawn_marker()
+ if spawn_marker != null:
+ _player.call("teleport_to_spawn", spawn_marker.global_transform)
+ else:
+ _player.global_position = player_spawn_position
+ _player.linear_velocity = Vector3.ZERO
+ _player.angular_velocity = Vector3.ZERO
+
+
+func _consume_spawn_marker() -> Node3D:
+ if TeleportState == null:
+ return null
+ var spawn_name: StringName = TeleportState.consume_spawn_name()
+ if spawn_name == StringName():
+ return null
+ return find_child(String(spawn_name), true, false) as Node3D
+
+
+func _update_day_night(delta: float) -> void:
+ if _sun == null or day_length <= 0.0:
+ return
+ _time = fmod(_time + delta, day_length)
+ var t: float = _time / day_length
+ var angle: float = lerp(start_light_angle, start_light_angle + 360.0, t)
+ _sun.rotation_degrees.x = angle
+ var energy_curve: float = -sin((t * TAU) + (start_light_angle * PI / 180.0))
+ _sun.light_energy = clamp((energy_curve * 1.0) + 0.2, 0.0, 1.2)
+
+
+func _setup_quest_ui() -> void:
+ if QuestManager == null:
+ return
+ if not QuestManager.is_connected("quest_state_changed", Callable(self, "_refresh_quest_ui")):
+ QuestManager.quest_state_changed.connect(_refresh_quest_ui)
+ _refresh_quest_ui()
+
+
+func _refresh_quest_ui() -> void:
+ if _quest_text == null or QuestManager == null:
+ return
+ var state: Dictionary = QuestManager.get_active_quest_state()
+ if not bool(state.get("active", false)):
+ _quest_text.text = "No active quest."
+ return
+ var title := String(state.get("title", "Quest"))
+ if bool(state.get("completed", false)):
+ _quest_text.text = "[b]%s[/b]\nComplete." % title
+ return
+ var step_index: int = int(state.get("current_step_index", 0))
+ var total_steps: int = int(state.get("total_steps", 0))
+ var step_text := String(state.get("current_step_text", ""))
+ _quest_text.text = "[b]%s[/b]\nStep %d/%d\n%s" % [title, step_index + 1, total_steps, step_text]
diff --git a/game/scenes/Levels/ashling_level.gd.uid b/game/scenes/Levels/ashling_level.gd.uid
new file mode 100644
index 0000000..5b5b53f
--- /dev/null
+++ b/game/scenes/Levels/ashling_level.gd.uid
@@ -0,0 +1 @@
+uid://dgqayogt2d14g
diff --git a/game/scenes/Levels/ashling_level.tscn b/game/scenes/Levels/ashling_level.tscn
new file mode 100644
index 0000000..7f6da6f
--- /dev/null
+++ b/game/scenes/Levels/ashling_level.tscn
@@ -0,0 +1,134 @@
+[gd_scene load_steps=11 format=3]
+
+[ext_resource type="Script" path="res://scenes/Levels/ashling_level.gd" id="1_level"]
+[ext_resource type="Script" path="res://scenes/player.gd" id="2_player"]
+[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="3_model"]
+[ext_resource type="PackedScene" path="res://scenes/Interaction/prototype_gateway.tscn" id="4_teleporter"]
+[ext_resource type="Material" path="res://assets/materials/kenney_prototype_ground_green.tres" id="5_ground_mat"]
+[ext_resource type="PackedScene" uid="uid://dp6jk0k3o4v1u" path="res://scenes/UI/pause_menu.tscn" id="6_pause_menu"]
+[ext_resource type="PackedScene" path="res://scenes/Characters/ashling_swarmer.tscn" id="7_ashling"]
+[ext_resource type="PackedScene" uid="uid://bnwpu7p8sbsfa" path="res://scenes/Interaction/RadialCommandMenu.tscn" id="8_radial_menu"]
+
+[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
+size = Vector3(1080, 2, 1080)
+
+[sub_resource type="BoxMesh" id="BoxMesh_ground"]
+material = ExtResource("5_ground_mat")
+size = Vector3(1080, 2, 1080)
+
+[node name="AshlingLevel" type="Node3D"]
+script = ExtResource("1_level")
+
+[node name="Ground" type="StaticBody3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
+shape = SubResource("BoxShape3D_ground")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
+mesh = SubResource("BoxMesh_ground")
+
+[node name="Player" type="RigidBody3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
+script = ExtResource("2_player")
+camera_path = NodePath("Camera3D")
+phone_path = NodePath("../PhoneUI")
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
+shape = SubResource("SphereShape3D_player")
+
+[node name="TestCharAnimated" parent="Player" instance=ExtResource("3_model")]
+transform = Transform3D(-0.9998549, 0, 0.01703362, 0, 1, 0, -0.01703362, 0, -0.9998549, 0, 0, 0)
+
+[node name="Camera3D" type="Camera3D" parent="Player"]
+transform = Transform3D(0.9989785, -4.651856e-10, -0.045188628, 0.006969331, 0.9880354, 0.15407, 0.044647958, -0.15422754, 0.9870261, 0.22036135, 1.8988357, 0.64972365)
+current = true
+fov = 49.0
+
+[node name="SpotLight3D" type="SpotLight3D" parent="Player"]
+transform = Transform3D(1, 0, 0, 0, 0.906308, -0.422618, 0, 0.422618, 0.906308, 0, 1.7, -0.35)
+visible = false
+spot_range = 30.0
+spot_angle = 25.0
+
+[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0)
+shadow_enabled = true
+
+[node name="EntrySpawn" type="Marker3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2)
+
+[node name="AshlingSwarmer" parent="." instance=ExtResource("7_ashling")]
+transform = Transform3D(0.8660254, 0, -0.5, 0, 1, 0, 0.5, 0, 0.8660254, 0, 0, -4)
+
+[node name="Label3D" type="Label3D" parent="AshlingSwarmer"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 2.6, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "ASHLING"
+
+[node name="ReturnTeleporter" parent="." instance=ExtResource("4_teleporter")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 5.5)
+target_scene_path = "res://scenes/Levels/level.tscn"
+target_spawn_name = &"AshlingReturnSpawn"
+
+[node name="Label3D" type="Label3D" parent="ReturnTeleporter"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "PLAYGROUND"
+
+[node name="PauseMenu" parent="." instance=ExtResource("6_pause_menu")]
+
+[node name="PhoneUI" type="CanvasLayer" parent="."]
+layer = 5
+visible = false
+
+[node name="Control" type="Control" parent="PhoneUI"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -180.0
+offset_top = -320.0
+offset_right = 180.0
+offset_bottom = 320.0
+grow_horizontal = 2
+grow_vertical = 2
+color = Color(0.08, 0.08, 0.1, 1)
+
+[node name="QuestTitle" type="Label" parent="PhoneUI/Control/PhoneFrame"]
+layout_mode = 0
+offset_left = 18.0
+offset_top = 18.0
+offset_right = 150.0
+offset_bottom = 41.0
+text = "Quest Log"
+
+[node name="QuestText" type="RichTextLabel" parent="PhoneUI/Control/PhoneFrame"]
+layout_mode = 0
+offset_left = 18.0
+offset_top = 52.0
+offset_right = 344.0
+offset_bottom = 613.0
+bbcode_enabled = true
+text = "No active quest."
+scroll_active = false
+
+[node name="RadialCommandMenu" parent="PhoneUI/Control" instance=ExtResource("8_radial_menu")]
+layout_mode = 1
diff --git a/game/scenes/Levels/level.gd b/game/scenes/Levels/level.gd
index 88c8a1c..a4cbe07 100644
--- a/game/scenes/Levels/level.gd
+++ b/game/scenes/Levels/level.gd
@@ -22,9 +22,9 @@ const FIRST_QUEST := {
"description": "Get familiar with movement and vehicles.",
"steps": [
{
- "id": "enter_vehicle",
- "text": "Get in the car (E).",
- "complete_event": "entered_vehicle",
+ "id": "enter_car_feature",
+ "text": "Walk through the car teleporter.",
+ "complete_event": "entered_car_feature",
},
{
"id": "reach_checkpoint",
@@ -35,6 +35,7 @@ const FIRST_QUEST := {
}
func _ready() -> void:
+ _move_player_to_requested_spawn()
_apply_sun_state(0.0)
_setup_quests()
if _should_show_spawn_dialog() and DialogSystem and DialogSystem.has_method("show_text"):
@@ -45,7 +46,6 @@ func _ready() -> void:
await get_tree().create_timer(spawn_dialog_auto_close_seconds).timeout
if DialogSystem and DialogSystem.has_method("close_if_text"):
DialogSystem.close_if_text(spawn_dialog_text)
- _show_quest_intro_dialog()
func _process(delta):
time = fmod((time + delta), day_length)
@@ -132,3 +132,23 @@ func _mark_spawn_dialog_shown() -> void:
if QuestManager == null:
return
QuestManager.set_meta(SPAWN_DIALOG_META_KEY, true)
+
+
+func _move_player_to_requested_spawn() -> void:
+ if _player == null or TeleportState == null:
+ return
+ var spawn_name: StringName = TeleportState.consume_spawn_name()
+ if spawn_name == StringName():
+ return
+ var spawn_marker := find_child(String(spawn_name), true, false) as Node3D
+ if spawn_marker == null:
+ return
+ if _player.has_method("teleport_to_spawn"):
+ _player.call("teleport_to_spawn", spawn_marker.global_transform)
+ elif _player is RigidBody3D:
+ var player_body := _player as RigidBody3D
+ player_body.global_transform = spawn_marker.global_transform
+ player_body.linear_velocity = Vector3.ZERO
+ player_body.angular_velocity = Vector3.ZERO
+ else:
+ (_player as Node3D).global_transform = spawn_marker.global_transform
diff --git a/game/scenes/Levels/level.tscn b/game/scenes/Levels/level.tscn
index 90497cd..26155a7 100644
--- a/game/scenes/Levels/level.tscn
+++ b/game/scenes/Levels/level.tscn
@@ -5,7 +5,6 @@
[ext_resource type="Script" uid="uid://bpxggc8nr6tf6" path="res://scenes/player.gd" id="1_muv8p"]
[ext_resource type="PackedScene" uid="uid://c5of6aaxop1hl" path="res://scenes/block.tscn" id="2_tc7dm"]
[ext_resource type="PackedScene" uid="uid://dp6jk0k3o4v1u" path="res://scenes/UI/pause_menu.tscn" id="3_pause_menu"]
-[ext_resource type="PackedScene" path="res://scenes/Characters/repo_bot.tscn" id="4_repo"]
[ext_resource type="PackedScene" path="res://scenes/Vehicles/car.tscn" id="5_car"]
[ext_resource type="PackedScene" uid="uid://bnqaqbgynoyys" path="res://assets/models/TestCharAnimated.glb" id="5_fi66n"]
[ext_resource type="Script" uid="uid://bk53njt7i3kmv" path="res://scenes/Interaction/dialog_trigger_area.gd" id="6_dialog"]
@@ -15,7 +14,6 @@
[ext_resource type="Material" path="res://assets/materials/kenney_prototype_ground_green.tres" id="9_ground_mat"]
[ext_resource type="Texture2D" uid="uid://c4ggdp0kg5wjk" path="res://addons/simplegrasstextured/textures/grassbushcc008.png" id="10_loupo"]
[ext_resource type="Script" uid="uid://2juaclm8gc1n" path="res://addons/simplegrasstextured/grass.gd" id="11_1meta"]
-[ext_resource type="Script" uid="uid://bv2ei5rg0dn3d" path="res://scenes/Levels/RadialCommandMenu.gd" id="15_18iqp"]
[ext_resource type="Texture2D" uid="uid://b2dmnrm3ubjon" path="res://assets/textures/stolenFire.png" id="16_i35yb"]
[ext_resource type="PackedScene" uid="uid://bnwpu7p8sbsfa" path="res://scenes/Interaction/RadialCommandMenu.tscn" id="16_px5jg"]
@@ -401,10 +399,6 @@ script = ExtResource("1_a4mo8")
[node name="human" parent="." unique_id=333471061 instance=ExtResource("1_eg4yq")]
-[node name="RepoBot" parent="." unique_id=1519051606 instance=ExtResource("4_repo")]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.9426608, 0, -4.4451966)
-look_target_path = NodePath("../Player")
-
[node name="Thing" type="RigidBody3D" parent="." unique_id=32794772]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -3.7986288)
physics_material_override = SubResource("PhysicsMaterial_2q6dc")
@@ -450,48 +444,6 @@ root_motion_track = NodePath("Armature/Skeleton3D:mixamorig_Hips")
tree_root = SubResource("AnimationNodeStateMachine_bwsrl")
anim_player = NodePath("../TestCharAnimated/AnimationPlayer")
-[node name="Car" parent="." unique_id=1231424399 instance=ExtResource("5_car")]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0, -3)
-
-[node name="DialogZone" type="Area3D" parent="." unique_id=616412893]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.5, 0, -2.5)
-script = ExtResource("6_dialog")
-prompt_text = "Press E to inspect area"
-dialog_text = "Dialog trigger area"
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="DialogZone" unique_id=1201310304]
-shape = SubResource("SphereShape3D_dialog_zone")
-
-[node name="Visual" type="MeshInstance3D" parent="DialogZone" unique_id=851990198]
-mesh = SubResource("SphereMesh_dialog_zone")
-
-[node name="AutoDialogZone" type="Area3D" parent="." unique_id=123053838]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4, 0, -6.5)
-script = ExtResource("6_dialog")
-dialog_text = "Auto dialog trigger"
-auto_popup = true
-auto_popup_close_on_exit = true
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="AutoDialogZone" unique_id=54253856]
-shape = SubResource("SphereShape3D_dialog_zone")
-
-[node name="Visual" type="MeshInstance3D" parent="AutoDialogZone" unique_id=1933827323]
-transform = Transform3D(0.8, 0, 0, 0, 0.8, 0, 0, 0, 0.8, 0, 0, 0)
-mesh = SubResource("SphereMesh_dialog_zone")
-
-[node name="QuestCheckpoint" type="Area3D" parent="." unique_id=1060088011]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0, -10)
-script = ExtResource("7_qtrigger")
-event_name = &"reach_checkpoint"
-target_group = &"vehicle"
-quest_id_filter = "first_drive"
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="QuestCheckpoint" unique_id=1399159753]
-shape = SubResource("SphereShape3D_checkpoint")
-
-[node name="Visual" type="MeshInstance3D" parent="QuestCheckpoint" unique_id=1488541765]
-mesh = SubResource("SphereMesh_checkpoint")
-
[node name="Ground" type="StaticBody3D" parent="." unique_id=1718381675]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
@@ -548,7 +500,6 @@ anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
-script = ExtResource("15_18iqp")
[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control" unique_id=1204675817]
layout_mode = 1
@@ -589,8 +540,66 @@ layout_mode = 1
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1808155101]
environment = SubResource("Environment_a4mo8")
-[node name="LevelExitTeleporter" parent="." unique_id=1508738453 instance=ExtResource("8_teleporter")]
+[node name="CarTeleporter" parent="." unique_id=1508738453 instance=ExtResource("8_teleporter")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0, 0)
+target_spawn_name = &"EntrySpawn"
+quest_event_name = &"entered_car_feature"
+quest_id_filter = "first_drive"
+
+[node name="Label3D" type="Label3D" parent="CarTeleporter"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "CAR"
+
+[node name="TriggerZonesTeleporter" parent="." instance=ExtResource("8_teleporter")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0, 6)
+target_scene_path = "res://scenes/Levels/trigger_zones_level.tscn"
+target_spawn_name = &"EntrySpawn"
+
+[node name="Label3D" type="Label3D" parent="TriggerZonesTeleporter"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "TRIGGERS"
+
+[node name="RepoBotTeleporter" parent="." instance=ExtResource("8_teleporter")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0, 12)
+target_scene_path = "res://scenes/Levels/repo_bot_level.tscn"
+target_spawn_name = &"EntrySpawn"
+
+[node name="Label3D" type="Label3D" parent="RepoBotTeleporter"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "REPO BOT"
+
+[node name="AshlingTeleporter" parent="." instance=ExtResource("8_teleporter")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0, 18)
+target_scene_path = "res://scenes/Levels/ashling_level.tscn"
+target_spawn_name = &"EntrySpawn"
+
+[node name="Label3D" type="Label3D" parent="AshlingTeleporter"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "ASHLING"
+
+[node name="CarReturnSpawn" type="Marker3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0, -3)
+
+[node name="TriggerZonesReturnSpawn" type="Marker3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0, 3)
+
+[node name="RepoBotReturnSpawn" type="Marker3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0, 9)
+
+[node name="AshlingReturnSpawn" type="Marker3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 5.5, 0, 15)
[node name="GPUParticles3D" type="GPUParticles3D" parent="." unique_id=2107882789]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.8036127, 1.8337743, -6.1764746)
diff --git a/game/scenes/Levels/location_level.gd b/game/scenes/Levels/location_level.gd
index 1de2177..aa891b4 100644
--- a/game/scenes/Levels/location_level.gd
+++ b/game/scenes/Levels/location_level.gd
@@ -3,6 +3,7 @@ extends Node3D
const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters"
const LOCATION_API_URL := "https://ploc.ranaze.com/api/Locations"
const INVENTORY_API_URL := "https://pinv.ranaze.com/api/inventory"
+const CRAFTING_API_URL := "https://pcraft.ranaze.com/api/crafting"
const MAIL_API_URL := "https://pmail.ranaze.com/api/mail"
const WORLD_API_URL := "https://pworld.ranaze.com/api/world"
const START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn"
@@ -11,20 +12,20 @@ const CHARACTER_SLOT_COUNT := 6
const VISIBLE_CHARACTERS_REFRESH_INTERVAL := 5.0
const HEARTBEAT_INTERVAL := 10.0
const WORLD_CYCLE_REFRESH_INTERVAL := 15.0
-
+
@export var tile_size := 8.0
@export var block_height := 1.0
@export var elevation_step_height := 0.5
@export var tile_wall_thickness := 0.08
@export_range(1, 8, 1) var tile_radius := 3
-@export var tracked_node_path: NodePath
-@export var player_spawn_height := 2.0
-@export var border_color: Color = Color(0.05, 0.05, 0.05, 1.0)
-@export var border_height_bias := 0.005
-@export var show_tile_labels := true
-@export var tile_label_height := 0.01
-@export var tile_label_color: Color = Color(1, 1, 1, 1)
-
+@export var tracked_node_path: NodePath
+@export var player_spawn_height := 2.0
+@export var border_color: Color = Color(0.05, 0.05, 0.05, 1.0)
+@export var border_height_bias := 0.005
+@export var show_tile_labels := true
+@export var tile_label_height := 0.01
+@export var tile_label_color: Color = Color(1, 1, 1, 1)
+
@onready var _block: MeshInstance3D = $TerrainBlock
@onready var _player: RigidBody3D = $Player
@onready var _camera: Camera3D = $Player/Camera3D
@@ -34,6 +35,7 @@ const WORLD_CYCLE_REFRESH_INTERVAL := 15.0
@onready var _inventory_location_label: Label = $InventoryMenu/MarginContainer/Panel/VBoxContainer/CurrentLocationLabel
@onready var _character_items_list: ItemList = $InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/CharacterPanel/VBoxContainer/CharacterItems
@onready var _ground_items_list: ItemList = $InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/GroundPanel/VBoxContainer/GroundItems
+@onready var _recipes_list: ItemList = $InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/RecipesPanel/VBoxContainer/RecipesList
@onready var _target_slot_spin_box: SpinBox = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ControlsRow/TargetSlotSpinBox
@onready var _quantity_spin_box: SpinBox = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ControlsRow/QuantitySpinBox
@onready var _inventory_status_label: Label = $InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/StatusLabel
@@ -52,13 +54,13 @@ var _center_coord := Vector2i.ZERO
var _tiles_root: Node3D
var _remote_players_root: Node3D
var _tracked_node: Node3D
-var _tile_nodes: Dictionary = {}
-var _camera_start_offset := Vector3(0.0, 6.0, 10.0)
-var _border_material: StandardMaterial3D
-var _biome_materials: Dictionary = {}
-var _known_locations: Dictionary = {}
-var _locations_loaded := false
-var _character_id := ""
+var _tile_nodes: Dictionary = {}
+var _camera_start_offset := Vector3(0.0, 6.0, 10.0)
+var _border_material: StandardMaterial3D
+var _biome_materials: Dictionary = {}
+var _known_locations: Dictionary = {}
+var _locations_loaded := false
+var _character_id := ""
var _persisted_coord := Vector2i.ZERO
var _coord_sync_in_flight := false
var _queued_coord_sync: Variant = null
@@ -67,8 +69,10 @@ var _queued_locations_refresh := false
var _interact_in_flight := false
var _inventory_request_in_flight := false
var _character_inventory_items: Array = []
+var _crafting_recipes: Array = []
var _selected_character_item_id := ""
var _selected_ground_item_id := ""
+var _selected_recipe_key := ""
var _visible_characters_in_flight := false
var _heartbeat_in_flight := false
var _visible_character_refresh_elapsed := 0.0
@@ -82,8 +86,8 @@ var _selected_sent_mail_id := ""
var _world_cycle_request_in_flight := false
var _world_cycle_refresh_elapsed := 0.0
var _world_cycle: Dictionary = {}
-
-
+
+
func _ready() -> void:
_tiles_root = Node3D.new()
_tiles_root.name = "GeneratedTiles"
@@ -91,15 +95,15 @@ func _ready() -> void:
_remote_players_root = Node3D.new()
_remote_players_root.name = "RemotePlayers"
add_child(_remote_players_root)
-
- if _camera:
- _camera_start_offset = _camera.position
-
- _tracked_node = get_node_or_null(tracked_node_path) as Node3D
- if _tracked_node == null:
- _tracked_node = _player
-
- var start_coord := SelectedCharacter.get_coord()
+
+ if _camera:
+ _camera_start_offset = _camera.position
+
+ _tracked_node = get_node_or_null(tracked_node_path) as Node3D
+ if _tracked_node == null:
+ _tracked_node = _player
+
+ var start_coord := SelectedCharacter.get_coord()
_center_coord = Vector2i(roundi(start_coord.x), roundi(start_coord.y))
_persisted_coord = _center_coord
_character_id = String(SelectedCharacter.character.get("id", SelectedCharacter.character.get("Id", ""))).strip_edges()
@@ -113,8 +117,8 @@ func _ready() -> void:
_rebuild_tiles(_center_coord)
_move_player_to_coord(_center_coord)
_activate_player_after_load()
-
-
+
+
func _process(_delta: float) -> void:
if not _locations_loaded:
return
@@ -133,14 +137,14 @@ func _process(_delta: float) -> void:
if _inventory_menu.visible or _mail_menu.visible:
return
var target_world_pos := _get_stream_position()
- var target_coord := _world_to_coord(target_world_pos)
- if target_coord == _center_coord:
- return
- _center_coord = target_coord
- _queue_coord_sync(_center_coord)
- _queue_locations_refresh()
-
-
+ var target_coord := _world_to_coord(target_world_pos)
+ if target_coord == _center_coord:
+ return
+ _center_coord = target_coord
+ _queue_coord_sync(_center_coord)
+ _queue_locations_refresh()
+
+
func _input(event: InputEvent) -> void:
if event.is_action_pressed("player_phone"):
if get_tree().paused or _mail_menu.visible:
@@ -166,21 +170,21 @@ func _unhandled_input(event: InputEvent) -> void:
if get_tree().paused or _inventory_menu.visible:
return
_try_interact_current_tile()
-
-
-func _get_stream_position() -> Vector3:
- if _tracked_node:
- return _tracked_node.global_position
- return _coord_to_world(_center_coord)
-
-
-func _world_to_coord(world_pos: Vector3) -> Vector2i:
- return Vector2i(
- roundi(world_pos.x / tile_size),
- roundi(world_pos.z / tile_size)
- )
-
-
+
+
+func _get_stream_position() -> Vector3:
+ if _tracked_node:
+ return _tracked_node.global_position
+ return _coord_to_world(_center_coord)
+
+
+func _world_to_coord(world_pos: Vector3) -> Vector2i:
+ return Vector2i(
+ roundi(world_pos.x / tile_size),
+ roundi(world_pos.z / tile_size)
+ )
+
+
func _coord_to_world(coord: Vector2i) -> Vector3:
return Vector3(coord.x * tile_size, _get_tile_center_y(coord), coord.y * tile_size)
@@ -264,8 +268,10 @@ func _close_inventory_menu() -> void:
_inventory_status_label.text = ""
_selected_character_item_id = ""
_selected_ground_item_id = ""
+ _selected_recipe_key = ""
_character_items_list.deselect_all()
_ground_items_list.deselect_all()
+ _recipes_list.deselect_all()
_set_player_menu_lock(false)
@@ -321,47 +327,47 @@ func _on_pause_main_menu_pressed() -> void:
_resume_game()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
get_tree().change_scene_to_file(START_SCREEN_SCENE)
-
-
-func _rebuild_tiles(center: Vector2i) -> void:
- var wanted_keys: Dictionary = {}
- for x in range(center.x - tile_radius, center.x + tile_radius + 1):
- for y in range(center.y - tile_radius, center.y + tile_radius + 1):
- var coord := Vector2i(x, y)
- if not _known_locations.has(coord):
- continue
+
+
+func _rebuild_tiles(center: Vector2i) -> void:
+ var wanted_keys: Dictionary = {}
+ for x in range(center.x - tile_radius, center.x + tile_radius + 1):
+ for y in range(center.y - tile_radius, center.y + tile_radius + 1):
+ var coord := Vector2i(x, y)
+ if not _known_locations.has(coord):
+ continue
wanted_keys[coord] = true
var location_data := _known_locations[coord] as Dictionary
if _tile_nodes.has(coord):
_update_tile(coord, location_data)
continue
_spawn_tile(coord, location_data)
-
- for key in _tile_nodes.keys():
- if wanted_keys.has(key):
- continue
- var tile_node := _tile_nodes[key] as Node3D
- if tile_node:
- tile_node.queue_free()
- _tile_nodes.erase(key)
-
-
+
+ for key in _tile_nodes.keys():
+ if wanted_keys.has(key):
+ continue
+ var tile_node := _tile_nodes[key] as Node3D
+ if tile_node:
+ tile_node.queue_free()
+ _tile_nodes.erase(key)
+
+
func _spawn_tile(coord: Vector2i, location_data: Dictionary) -> void:
var tile_root := Node3D.new()
- tile_root.name = "Tile_%d_%d" % [coord.x, coord.y]
- tile_root.position = _coord_to_world(coord)
- _tiles_root.add_child(tile_root)
-
- var tile_body := StaticBody3D.new()
- tile_body.name = "TileBody"
- tile_body.scale = Vector3(tile_size, block_height, tile_size)
- tile_root.add_child(tile_body)
-
- var collision_shape := CollisionShape3D.new()
- collision_shape.name = "CollisionShape3D"
- collision_shape.shape = BoxShape3D.new()
- tile_body.add_child(collision_shape)
-
+ tile_root.name = "Tile_%d_%d" % [coord.x, coord.y]
+ tile_root.position = _coord_to_world(coord)
+ _tiles_root.add_child(tile_root)
+
+ var tile_body := StaticBody3D.new()
+ tile_body.name = "TileBody"
+ tile_body.scale = Vector3(tile_size, block_height, tile_size)
+ tile_root.add_child(tile_body)
+
+ var collision_shape := CollisionShape3D.new()
+ collision_shape.name = "CollisionShape3D"
+ collision_shape.shape = BoxShape3D.new()
+ tile_body.add_child(collision_shape)
+
var tile := _block.duplicate() as MeshInstance3D
tile.name = "TileMesh"
tile.visible = true
@@ -379,8 +385,8 @@ func _spawn_tile(coord: Vector2i, location_data: Dictionary) -> void:
_update_tile_inventory(tile_root, location_data)
_tile_nodes[coord] = tile_root
-
-
+
+
func _update_tile(coord: Vector2i, location_data: Dictionary) -> void:
var tile_root := _tile_nodes.get(coord) as Node3D
if tile_root == null:
@@ -399,8 +405,8 @@ func _update_tile(coord: Vector2i, location_data: Dictionary) -> void:
_update_tile_object(tile_root, location_data)
_update_tile_inventory(tile_root, location_data)
-
-
+
+
func _update_tile_object(tile_root: Node3D, location_data: Dictionary) -> void:
var object_data_variant: Variant = location_data.get("locationObject", {})
if typeof(object_data_variant) != TYPE_DICTIONARY:
@@ -463,28 +469,28 @@ func _update_tile_inventory(tile_root: Node3D, location_data: Dictionary) -> voi
tile_root.add_child(existing_label)
existing_label.text = _build_floor_inventory_label(floor_items)
-
-
+
+
func _create_tile_border() -> MeshInstance3D:
- var top_y := 0.5 + border_height_bias
- var corners := [
- Vector3(-0.5, top_y, -0.5),
- Vector3(0.5, top_y, -0.5),
- Vector3(0.5, top_y, 0.5),
- Vector3(-0.5, top_y, 0.5),
- ]
-
- var border_mesh := ImmediateMesh.new()
- border_mesh.surface_begin(Mesh.PRIMITIVE_LINES, _get_border_material())
- for idx in range(corners.size()):
- var current: Vector3 = corners[idx]
- var next: Vector3 = corners[(idx + 1) % corners.size()]
- border_mesh.surface_add_vertex(current)
- border_mesh.surface_add_vertex(next)
- border_mesh.surface_end()
-
- var border := MeshInstance3D.new()
- border.name = "TileBorder"
+ var top_y := 0.5 + border_height_bias
+ var corners := [
+ Vector3(-0.5, top_y, -0.5),
+ Vector3(0.5, top_y, -0.5),
+ Vector3(0.5, top_y, 0.5),
+ Vector3(-0.5, top_y, 0.5),
+ ]
+
+ var border_mesh := ImmediateMesh.new()
+ border_mesh.surface_begin(Mesh.PRIMITIVE_LINES, _get_border_material())
+ for idx in range(corners.size()):
+ var current: Vector3 = corners[idx]
+ var next: Vector3 = corners[(idx + 1) % corners.size()]
+ border_mesh.surface_add_vertex(current)
+ border_mesh.surface_add_vertex(next)
+ border_mesh.surface_end()
+
+ var border := MeshInstance3D.new()
+ border.name = "TileBorder"
border.mesh = border_mesh
return border
@@ -526,39 +532,39 @@ func _update_tile_edge_walls(tile_root: Node3D, coord: Vector2i, biome_material:
if biome_material:
wall.material_override = biome_material
walls_root.add_child(wall)
-
-
-func _get_border_material() -> StandardMaterial3D:
- if _border_material:
- return _border_material
-
- _border_material = StandardMaterial3D.new()
- _border_material.albedo_color = border_color
- _border_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
- _border_material.disable_receive_shadows = true
- _border_material.no_depth_test = true
- return _border_material
-
-
-func _create_tile_label(location_name: String) -> Label3D:
- var label := Label3D.new()
- label.name = "LocationNameLabel"
- label.text = location_name
- label.position = Vector3(0.0, (block_height * 0.5) + border_height_bias + tile_label_height, 0.0)
- label.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
- label.billboard = BaseMaterial3D.BILLBOARD_DISABLED
- label.modulate = tile_label_color
- label.pixel_size = 0.01
- label.outline_size = 12
- label.no_depth_test = false
- return label
-
-
-func _create_object_material(object_key: String) -> StandardMaterial3D:
- var material := StandardMaterial3D.new()
- material.roughness = 1.0
- material.metallic = 0.0
-
+
+
+func _get_border_material() -> StandardMaterial3D:
+ if _border_material:
+ return _border_material
+
+ _border_material = StandardMaterial3D.new()
+ _border_material.albedo_color = border_color
+ _border_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
+ _border_material.disable_receive_shadows = true
+ _border_material.no_depth_test = true
+ return _border_material
+
+
+func _create_tile_label(location_name: String) -> Label3D:
+ var label := Label3D.new()
+ label.name = "LocationNameLabel"
+ label.text = location_name
+ label.position = Vector3(0.0, (block_height * 0.5) + border_height_bias + tile_label_height, 0.0)
+ label.rotation_degrees = Vector3(-90.0, 0.0, 0.0)
+ label.billboard = BaseMaterial3D.BILLBOARD_DISABLED
+ label.modulate = tile_label_color
+ label.pixel_size = 0.01
+ label.outline_size = 12
+ label.no_depth_test = false
+ return label
+
+
+func _create_object_material(object_key: String) -> StandardMaterial3D:
+ var material := StandardMaterial3D.new()
+ material.roughness = 1.0
+ material.metallic = 0.0
+
if object_key.contains("grass"):
material.albedo_color = Color(0.28, 0.68, 0.25, 1.0)
elif object_key.contains("wood"):
@@ -569,16 +575,16 @@ func _create_object_material(object_key: String) -> StandardMaterial3D:
material.albedo_color = Color(0.22, 0.44, 0.78, 1.0)
else:
material.albedo_color = Color(0.85, 0.75, 0.3, 1.0)
-
- return material
-
-
+
+ return material
+
+
func _build_object_label(object_data: Dictionary) -> String:
- var object_name := String(object_data.get("name", "")).strip_edges()
- var state_variant: Variant = object_data.get("state", {})
- var remaining_quantity := 0
- if typeof(state_variant) == TYPE_DICTIONARY:
- remaining_quantity = int((state_variant as Dictionary).get("remainingQuantity", 0))
+ var object_name := String(object_data.get("name", "")).strip_edges()
+ var state_variant: Variant = object_data.get("state", {})
+ var remaining_quantity := 0
+ if typeof(state_variant) == TYPE_DICTIONARY:
+ remaining_quantity = int((state_variant as Dictionary).get("remainingQuantity", 0))
if object_name.is_empty():
object_name = "Object"
return "%s x%d" % [object_name, remaining_quantity]
@@ -647,6 +653,7 @@ func _refresh_inventory_menu_data_async() -> void:
_inventory_request_in_flight = true
_update_inventory_location_label()
_character_inventory_items = await _fetch_character_inventory()
+ _crafting_recipes = await _fetch_available_recipes()
var location_id := _get_current_location_id()
if not location_id.is_empty():
await _refresh_location_inventory(location_id)
@@ -657,6 +664,7 @@ func _refresh_inventory_menu_data_async() -> void:
func _render_inventory_menu() -> void:
var current_character_selection := _selected_character_item_id
var current_ground_selection := _selected_ground_item_id
+ var current_recipe_selection := _selected_recipe_key
_character_items_list.clear()
var slot_map := {}
@@ -698,13 +706,36 @@ func _render_inventory_menu() -> void:
if not current_ground_selection.is_empty() and String(floor_item.get("itemId", floor_item.get("id", ""))).strip_edges() == current_ground_selection:
_ground_items_list.select(index)
+ _recipes_list.clear()
+ for index in range(_crafting_recipes.size()):
+ var recipe_entry_variant: Variant = _crafting_recipes[index]
+ if typeof(recipe_entry_variant) != TYPE_DICTIONARY:
+ continue
+ var recipe_entry := recipe_entry_variant as Dictionary
+ var recipe: Dictionary = recipe_entry.get("recipe", {})
+ var recipe_key := String(recipe.get("recipeKey", "")).strip_edges()
+ var marker := "[OK]" if bool(recipe_entry.get("canCraft", false)) else "[--]"
+ var recipe_text := "%s %s: %s -> %s" % [
+ marker,
+ String(recipe.get("name", recipe_key)).strip_edges(),
+ _format_ingredients(recipe.get("inputs", [])),
+ _format_ingredients(recipe.get("outputs", []))
+ ]
+ _recipes_list.add_item(recipe_text)
+ var recipe_list_index := _recipes_list.get_item_count() - 1
+ _recipes_list.set_item_metadata(recipe_list_index, recipe_entry)
+ if not current_recipe_selection.is_empty() and recipe_key == current_recipe_selection:
+ _recipes_list.select(recipe_list_index)
+
_update_inventory_controls()
func _update_inventory_controls() -> void:
var selected_item := _get_selected_inventory_item()
var max_quantity := 1
- if not selected_item.is_empty():
+ if not _selected_recipe_key.is_empty():
+ max_quantity = 999
+ elif not selected_item.is_empty():
max_quantity = max(1, int(selected_item.get("quantity", 1)))
if _selected_ground_item_id == String(selected_item.get("itemId", selected_item.get("id", ""))).strip_edges():
_target_slot_spin_box.value = float(_default_slot_for_item(selected_item))
@@ -733,6 +764,43 @@ func _find_item_by_id(items: Array, item_id: String) -> Dictionary:
return {}
+func _find_recipe_entry(recipe_key: String) -> Dictionary:
+ for recipe_entry_variant in _crafting_recipes:
+ if typeof(recipe_entry_variant) != TYPE_DICTIONARY:
+ continue
+ var recipe_entry := recipe_entry_variant as Dictionary
+ var recipe: Dictionary = recipe_entry.get("recipe", {})
+ if String(recipe.get("recipeKey", "")).strip_edges() == recipe_key:
+ return recipe_entry
+ return {}
+
+
+func _format_ingredients(value: Variant) -> String:
+ if typeof(value) != TYPE_ARRAY:
+ return "-"
+ var parts := PackedStringArray()
+ for ingredient_variant in value:
+ if typeof(ingredient_variant) != TYPE_DICTIONARY:
+ continue
+ var ingredient := ingredient_variant as Dictionary
+ var item_key := String(ingredient.get("itemKey", "")).strip_edges()
+ if item_key.is_empty():
+ continue
+ parts.append("%s x%d" % [item_key, int(ingredient.get("quantity", 0))])
+ return ", ".join(parts) if parts.size() > 0 else "-"
+
+
+func _format_messages(value: Variant, fallback: String) -> String:
+ if typeof(value) != TYPE_ARRAY:
+ return fallback
+ var parts := PackedStringArray()
+ for message_variant in value:
+ var message := String(message_variant).strip_edges()
+ if not message.is_empty():
+ parts.append(message)
+ return ", ".join(parts) if parts.size() > 0 else fallback
+
+
func _get_current_location_id() -> String:
var location_data := _get_location_data(_center_coord)
return String(location_data.get("id", "")).strip_edges()
@@ -774,7 +842,9 @@ func _first_open_character_slot() -> int:
func _on_character_items_selected(index: int) -> void:
var metadata: Variant = _character_items_list.get_item_metadata(index)
_selected_ground_item_id = ""
+ _selected_recipe_key = ""
_ground_items_list.deselect_all()
+ _recipes_list.deselect_all()
if typeof(metadata) != TYPE_DICTIONARY or (metadata as Dictionary).is_empty():
_selected_character_item_id = ""
else:
@@ -787,7 +857,9 @@ func _on_character_items_selected(index: int) -> void:
func _on_ground_items_selected(index: int) -> void:
var metadata: Variant = _ground_items_list.get_item_metadata(index)
_selected_character_item_id = ""
+ _selected_recipe_key = ""
_character_items_list.deselect_all()
+ _recipes_list.deselect_all()
if typeof(metadata) != TYPE_DICTIONARY or (metadata as Dictionary).is_empty():
_selected_ground_item_id = ""
else:
@@ -797,6 +869,22 @@ func _on_ground_items_selected(index: int) -> void:
_update_inventory_controls()
+func _on_recipe_selected(index: int) -> void:
+ var metadata: Variant = _recipes_list.get_item_metadata(index)
+ _selected_character_item_id = ""
+ _selected_ground_item_id = ""
+ _character_items_list.deselect_all()
+ _ground_items_list.deselect_all()
+ if typeof(metadata) != TYPE_DICTIONARY or (metadata as Dictionary).is_empty():
+ _selected_recipe_key = ""
+ else:
+ var recipe_entry := metadata as Dictionary
+ var recipe: Dictionary = recipe_entry.get("recipe", {})
+ _selected_recipe_key = String(recipe.get("recipeKey", "")).strip_edges()
+ _inventory_status_label.text = _format_messages(recipe_entry.get("missingRequirements", []), "Recipe selected.")
+ _update_inventory_controls()
+
+
func _on_inventory_move_pressed() -> void:
if _selected_character_item_id.is_empty():
_inventory_status_label.text = "Select a character item first."
@@ -830,6 +918,20 @@ func _on_inventory_pickup_pressed() -> void:
_transfer_item_async(_selected_ground_item_id, "location", location_id, "character", _character_id, null, quantity, "Picked up item.")
+func _on_inventory_craft_pressed() -> void:
+ if _selected_recipe_key.is_empty():
+ _inventory_status_label.text = "Select a recipe first."
+ return
+ var recipe_entry := _find_recipe_entry(_selected_recipe_key)
+ if recipe_entry.is_empty():
+ _inventory_status_label.text = "Selected recipe is no longer available."
+ return
+ if not bool(recipe_entry.get("canCraft", false)):
+ _inventory_status_label.text = _format_messages(recipe_entry.get("missingRequirements", []), "Recipe requirements are missing.")
+ return
+ _craft_recipe_async(_selected_recipe_key, int(_quantity_spin_box.value))
+
+
func _on_inventory_refresh_pressed() -> void:
_inventory_status_label.text = "Refreshing..."
_refresh_inventory_menu_data()
@@ -922,6 +1024,88 @@ func _handle_inventory_mutation_response(result: Array, success_message: String)
_refresh_inventory_menu_data()
+func _craft_recipe_async(recipe_key: String, quantity: int) -> void:
+ if _inventory_request_in_flight:
+ return
+ _inventory_request_in_flight = true
+ _inventory_status_label.text = "Crafting..."
+
+ var request := HTTPRequest.new()
+ add_child(request)
+
+ var headers := PackedStringArray()
+ if not AuthState.access_token.is_empty():
+ headers.append("Authorization: Bearer %s" % AuthState.access_token)
+ headers.append("Content-Type: application/json")
+
+ var body := JSON.stringify({
+ "recipeKey": recipe_key,
+ "quantity": max(1, quantity),
+ "locationId": _get_current_location_id()
+ })
+ var err := request.request("%s/characters/%s/craft" % [CRAFTING_API_URL, _character_id], headers, HTTPClient.METHOD_POST, body)
+ if err != OK:
+ request.queue_free()
+ _inventory_status_label.text = "Craft request failed."
+ _inventory_request_in_flight = false
+ return
+
+ var result: Array = await request.request_completed
+ request.queue_free()
+ _inventory_request_in_flight = false
+
+ var result_code: int = result[0]
+ var response_code: int = result[1]
+ var response_body: String = result[3].get_string_from_utf8()
+ if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
+ var parsed_error: Variant = JSON.parse_string(response_body)
+ if typeof(parsed_error) == TYPE_DICTIONARY:
+ _inventory_status_label.text = _format_messages((parsed_error as Dictionary).get("missingRequirements", []), "Crafting failed.")
+ else:
+ _inventory_status_label.text = "Crafting failed."
+ push_warning("Crafting failed (%s/%s): %s" % [result_code, response_code, response_body])
+ _refresh_inventory_menu_data()
+ return
+
+ var parsed: Variant = JSON.parse_string(response_body)
+ var produced_text := "Crafted."
+ if typeof(parsed) == TYPE_DICTIONARY:
+ produced_text = "Crafted %s." % _format_ingredients((parsed as Dictionary).get("produced", []))
+ _inventory_status_label.text = produced_text
+ _refresh_inventory_menu_data()
+
+
+func _fetch_available_recipes() -> Array:
+ if _character_id.is_empty():
+ return []
+
+ var request := HTTPRequest.new()
+ add_child(request)
+
+ var headers := PackedStringArray()
+ if not AuthState.access_token.is_empty():
+ headers.append("Authorization: Bearer %s" % AuthState.access_token)
+
+ var err := request.request("%s/characters/%s/available-recipes" % [CRAFTING_API_URL, _character_id], headers, HTTPClient.METHOD_GET)
+ if err != OK:
+ request.queue_free()
+ push_warning("Failed to request crafting recipes: %s" % err)
+ return []
+
+ var result: Array = await request.request_completed
+ request.queue_free()
+
+ var result_code: int = result[0]
+ var response_code: int = result[1]
+ var response_body: String = result[3].get_string_from_utf8()
+ if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
+ push_warning("Failed to load crafting recipes (%s/%s): %s" % [result_code, response_code, response_body])
+ return []
+
+ var parsed: Variant = JSON.parse_string(response_body)
+ return parsed if typeof(parsed) == TYPE_ARRAY else []
+
+
func _fetch_character_inventory() -> Array:
var request := HTTPRequest.new()
add_child(request)
@@ -1212,11 +1396,11 @@ func _send_mail_async(recipient_name: String, subject: String, body: String) ->
func _on_mail_close_pressed() -> void:
_close_mail_menu()
-
-
-func _ensure_selected_location_exists(coord: Vector2i) -> void:
- if _known_locations.has(coord):
- return
+
+
+func _ensure_selected_location_exists(coord: Vector2i) -> void:
+ if _known_locations.has(coord):
+ return
_known_locations[coord] = {
"id": "",
"name": _selected_location_name(coord),
@@ -1225,76 +1409,76 @@ func _ensure_selected_location_exists(coord: Vector2i) -> void:
"locationObject": {},
"floorItems": []
}
-
-
-func _selected_location_name(coord: Vector2i) -> String:
- var selected_name := String(SelectedCharacter.character.get("locationName", "")).strip_edges()
- if not selected_name.is_empty():
- return selected_name
- var character_name := String(SelectedCharacter.character.get("name", "")).strip_edges()
- if not character_name.is_empty():
- return "%s's Location" % character_name
- return "Location %d,%d" % [coord.x, coord.y]
-
-
+
+
+func _selected_location_name(coord: Vector2i) -> String:
+ var selected_name := String(SelectedCharacter.character.get("locationName", "")).strip_edges()
+ if not selected_name.is_empty():
+ return selected_name
+ var character_name := String(SelectedCharacter.character.get("name", "")).strip_edges()
+ if not character_name.is_empty():
+ return "%s's Location" % character_name
+ return "Location %d,%d" % [coord.x, coord.y]
+
+
func _load_existing_locations() -> void:
- _locations_refresh_in_flight = true
- _locations_loaded = false
- _known_locations.clear()
-
- if _character_id.is_empty():
- push_warning("Selected character is missing an id; cannot load visible locations.")
- _locations_loaded = true
- _locations_refresh_in_flight = false
- return
-
- var request := HTTPRequest.new()
- add_child(request)
-
- var headers := PackedStringArray()
- if not AuthState.access_token.is_empty():
- headers.append("Authorization: Bearer %s" % AuthState.access_token)
-
- var err := request.request("%s/%s/visible-locations" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET)
- if err != OK:
- push_warning("Failed to request visible locations: %s" % err)
- request.queue_free()
- _locations_loaded = true
- _locations_refresh_in_flight = false
- return
-
- var result: Array = await request.request_completed
- request.queue_free()
-
- var result_code: int = result[0]
- var response_code: int = result[1]
- var response_body: String = result[3].get_string_from_utf8()
- if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
- push_warning("Failed to load visible locations (%s/%s): %s" % [result_code, response_code, response_body])
- _locations_loaded = true
- _locations_refresh_in_flight = false
- return
-
- var parsed: Variant = JSON.parse_string(response_body)
- if typeof(parsed) != TYPE_ARRAY:
- push_warning("Visible locations response was not an array.")
- _locations_loaded = true
- _locations_refresh_in_flight = false
- return
-
- var loaded_count := 0
- for item in parsed:
- if typeof(item) != TYPE_DICTIONARY:
- continue
- var location := item as Dictionary
- var coord_variant: Variant = location.get("coord", {})
- if typeof(coord_variant) != TYPE_DICTIONARY:
- continue
- var coord_dict := coord_variant as Dictionary
- var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0)))
- var location_name := String(location.get("name", "")).strip_edges()
- if location_name.is_empty():
- location_name = "Location %d,%d" % [coord.x, coord.y]
+ _locations_refresh_in_flight = true
+ _locations_loaded = false
+ _known_locations.clear()
+
+ if _character_id.is_empty():
+ push_warning("Selected character is missing an id; cannot load visible locations.")
+ _locations_loaded = true
+ _locations_refresh_in_flight = false
+ return
+
+ var request := HTTPRequest.new()
+ add_child(request)
+
+ var headers := PackedStringArray()
+ if not AuthState.access_token.is_empty():
+ headers.append("Authorization: Bearer %s" % AuthState.access_token)
+
+ var err := request.request("%s/%s/visible-locations" % [CHARACTER_API_URL, _character_id], headers, HTTPClient.METHOD_GET)
+ if err != OK:
+ push_warning("Failed to request visible locations: %s" % err)
+ request.queue_free()
+ _locations_loaded = true
+ _locations_refresh_in_flight = false
+ return
+
+ var result: Array = await request.request_completed
+ request.queue_free()
+
+ var result_code: int = result[0]
+ var response_code: int = result[1]
+ var response_body: String = result[3].get_string_from_utf8()
+ if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
+ push_warning("Failed to load visible locations (%s/%s): %s" % [result_code, response_code, response_body])
+ _locations_loaded = true
+ _locations_refresh_in_flight = false
+ return
+
+ var parsed: Variant = JSON.parse_string(response_body)
+ if typeof(parsed) != TYPE_ARRAY:
+ push_warning("Visible locations response was not an array.")
+ _locations_loaded = true
+ _locations_refresh_in_flight = false
+ return
+
+ var loaded_count := 0
+ for item in parsed:
+ if typeof(item) != TYPE_DICTIONARY:
+ continue
+ var location := item as Dictionary
+ var coord_variant: Variant = location.get("coord", {})
+ if typeof(coord_variant) != TYPE_DICTIONARY:
+ continue
+ var coord_dict := coord_variant as Dictionary
+ var coord := Vector2i(int(coord_dict.get("x", 0)), int(coord_dict.get("y", 0)))
+ var location_name := String(location.get("name", "")).strip_edges()
+ if location_name.is_empty():
+ location_name = "Location %d,%d" % [coord.x, coord.y]
_known_locations[coord] = {
"id": String(location.get("id", "")).strip_edges(),
"name": location_name,
@@ -1316,21 +1500,21 @@ func _load_existing_locations() -> void:
if _queued_locations_refresh:
_queued_locations_refresh = false
_queue_locations_refresh()
-
-
-func _queue_locations_refresh() -> void:
- if _locations_refresh_in_flight:
- _queued_locations_refresh = true
- return
- _refresh_visible_locations()
-
-
-func _refresh_visible_locations() -> void:
- if _character_id.is_empty():
- return
- _refresh_visible_locations_async()
-
-
+
+
+func _queue_locations_refresh() -> void:
+ if _locations_refresh_in_flight:
+ _queued_locations_refresh = true
+ return
+ _refresh_visible_locations()
+
+
+func _refresh_visible_locations() -> void:
+ if _character_id.is_empty():
+ return
+ _refresh_visible_locations_async()
+
+
func _refresh_visible_locations_async() -> void:
await _load_existing_locations()
@@ -1377,70 +1561,70 @@ func _refresh_world_cycle_async() -> void:
_world_cycle = parsed as Dictionary
_world_cycle_request_in_flight = false
-
-
-func _try_interact_current_tile() -> void:
- if _interact_in_flight:
- return
- if _character_id.is_empty():
- return
-
- var location_data: Dictionary = _known_locations.get(_center_coord, {})
- if location_data.is_empty():
- push_warning("No known location data for %s." % _center_coord)
- return
-
- var location_id := String(location_data.get("id", "")).strip_edges()
- if location_id.is_empty():
- push_warning("Current location is missing an id.")
- return
-
- var object_data: Dictionary = location_data.get("locationObject", {})
- if object_data.is_empty():
- push_warning("Current location has no interactable object.")
- return
-
- var object_id := String(object_data.get("id", "")).strip_edges()
- if object_id.is_empty():
- push_warning("Current location object is missing an id.")
- return
-
- _interact_in_flight = true
- _interact_with_location_async(location_id, object_id)
-
-
+
+
+func _try_interact_current_tile() -> void:
+ if _interact_in_flight:
+ return
+ if _character_id.is_empty():
+ return
+
+ var location_data: Dictionary = _known_locations.get(_center_coord, {})
+ if location_data.is_empty():
+ push_warning("No known location data for %s." % _center_coord)
+ return
+
+ var location_id := String(location_data.get("id", "")).strip_edges()
+ if location_id.is_empty():
+ push_warning("Current location is missing an id.")
+ return
+
+ var object_data: Dictionary = location_data.get("locationObject", {})
+ if object_data.is_empty():
+ push_warning("Current location has no interactable object.")
+ return
+
+ var object_id := String(object_data.get("id", "")).strip_edges()
+ if object_id.is_empty():
+ push_warning("Current location object is missing an id.")
+ return
+
+ _interact_in_flight = true
+ _interact_with_location_async(location_id, object_id)
+
+
func _interact_with_location_async(location_id: String, object_id: String) -> void:
- var request := HTTPRequest.new()
- add_child(request)
-
- var headers := PackedStringArray()
- if not AuthState.access_token.is_empty():
- headers.append("Authorization: Bearer %s" % AuthState.access_token)
- headers.append("Content-Type: application/json")
-
- var body := JSON.stringify({
- "characterId": _character_id,
- "objectId": object_id
- })
- var err := request.request("%s/%s/interact" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body)
- if err != OK:
- push_warning("Failed to send location interaction request: %s" % err)
- request.queue_free()
- _interact_in_flight = false
- return
-
- var result: Array = await request.request_completed
- request.queue_free()
-
- var result_code: int = result[0]
- var response_code: int = result[1]
- var response_body: String = result[3].get_string_from_utf8()
- if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
- push_warning("Location interaction failed (%s/%s): %s" % [result_code, response_code, response_body])
- _interact_in_flight = false
- return
-
- var parsed: Variant = JSON.parse_string(response_body)
+ var request := HTTPRequest.new()
+ add_child(request)
+
+ var headers := PackedStringArray()
+ if not AuthState.access_token.is_empty():
+ headers.append("Authorization: Bearer %s" % AuthState.access_token)
+ headers.append("Content-Type: application/json")
+
+ var body := JSON.stringify({
+ "characterId": _character_id,
+ "objectId": object_id
+ })
+ var err := request.request("%s/%s/interact" % [LOCATION_API_URL, location_id], headers, HTTPClient.METHOD_POST, body)
+ if err != OK:
+ push_warning("Failed to send location interaction request: %s" % err)
+ request.queue_free()
+ _interact_in_flight = false
+ return
+
+ var result: Array = await request.request_completed
+ request.queue_free()
+
+ var result_code: int = result[0]
+ var response_code: int = result[1]
+ var response_body: String = result[3].get_string_from_utf8()
+ if result_code != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
+ push_warning("Location interaction failed (%s/%s): %s" % [result_code, response_code, response_body])
+ _interact_in_flight = false
+ return
+
+ var parsed: Variant = JSON.parse_string(response_body)
if typeof(parsed) != TYPE_DICTIONARY:
push_warning("Location interaction response was not an object.")
_interact_in_flight = false
@@ -1454,49 +1638,49 @@ func _interact_with_location_async(location_id: String, object_id: String) -> vo
_apply_interaction_result(location_id, interaction)
await _refresh_location_inventory(location_id)
_interact_in_flight = false
-
-
-func _apply_interaction_result(location_id: String, interaction: Dictionary) -> void:
- var consumed := bool(interaction.get("consumed", false))
- var remaining_quantity := int(interaction.get("remainingQuantity", 0))
-
- for coord_variant in _known_locations.keys():
- var coord: Vector2i = coord_variant
- var location_data: Dictionary = _known_locations[coord]
- if String(location_data.get("id", "")) != location_id:
- continue
-
- var updated_location := location_data.duplicate(true)
- if consumed:
- updated_location["locationObject"] = {}
- else:
- var object_data: Dictionary = updated_location.get("locationObject", {})
- var state: Dictionary = object_data.get("state", {})
- state["remainingQuantity"] = remaining_quantity
- object_data["state"] = state
- updated_location["locationObject"] = object_data
- _known_locations[coord] = updated_location
- _update_tile(coord, updated_location)
- return
-
-
-func _queue_coord_sync(coord: Vector2i) -> void:
- if coord == _persisted_coord:
- return
- if _coord_sync_in_flight:
- _queued_coord_sync = coord
- return
- _sync_character_coord(coord)
-
-
-func _sync_character_coord(coord: Vector2i) -> void:
- if _character_id.is_empty():
- return
- _coord_sync_in_flight = true
- _queued_coord_sync = null
- _sync_character_coord_async(coord)
-
-
+
+
+func _apply_interaction_result(location_id: String, interaction: Dictionary) -> void:
+ var consumed := bool(interaction.get("consumed", false))
+ var remaining_quantity := int(interaction.get("remainingQuantity", 0))
+
+ for coord_variant in _known_locations.keys():
+ var coord: Vector2i = coord_variant
+ var location_data: Dictionary = _known_locations[coord]
+ if String(location_data.get("id", "")) != location_id:
+ continue
+
+ var updated_location := location_data.duplicate(true)
+ if consumed:
+ updated_location["locationObject"] = {}
+ else:
+ var object_data: Dictionary = updated_location.get("locationObject", {})
+ var state: Dictionary = object_data.get("state", {})
+ state["remainingQuantity"] = remaining_quantity
+ object_data["state"] = state
+ updated_location["locationObject"] = object_data
+ _known_locations[coord] = updated_location
+ _update_tile(coord, updated_location)
+ return
+
+
+func _queue_coord_sync(coord: Vector2i) -> void:
+ if coord == _persisted_coord:
+ return
+ if _coord_sync_in_flight:
+ _queued_coord_sync = coord
+ return
+ _sync_character_coord(coord)
+
+
+func _sync_character_coord(coord: Vector2i) -> void:
+ if _character_id.is_empty():
+ return
+ _coord_sync_in_flight = true
+ _queued_coord_sync = null
+ _sync_character_coord_async(coord)
+
+
func _sync_character_coord_async(coord: Vector2i) -> void:
var response := await CharacterService.update_character_coord(_character_id, coord)
if response.get("ok", false):
@@ -1504,27 +1688,27 @@ func _sync_character_coord_async(coord: Vector2i) -> void:
SelectedCharacter.set_coord(coord)
_refresh_visible_characters()
else:
- push_warning("Failed to persist character coord to %s,%s: status=%s error=%s body=%s" % [
- coord.x,
- coord.y,
- response.get("status", "n/a"),
- response.get("error", ""),
- response.get("body", "")
- ])
-
- _coord_sync_in_flight = false
- if _queued_coord_sync != null and _queued_coord_sync is Vector2i and _queued_coord_sync != _persisted_coord:
- var queued_coord: Vector2i = _queued_coord_sync
- _sync_character_coord(queued_coord)
-
-
+ push_warning("Failed to persist character coord to %s,%s: status=%s error=%s body=%s" % [
+ coord.x,
+ coord.y,
+ response.get("status", "n/a"),
+ response.get("error", ""),
+ response.get("body", "")
+ ])
+
+ _coord_sync_in_flight = false
+ if _queued_coord_sync != null and _queued_coord_sync is Vector2i and _queued_coord_sync != _persisted_coord:
+ var queued_coord: Vector2i = _queued_coord_sync
+ _sync_character_coord(queued_coord)
+
+
func _get_location_data(coord: Vector2i) -> Dictionary:
var value: Variant = _known_locations.get(coord, {})
if typeof(value) == TYPE_DICTIONARY:
return value as Dictionary
return {}
-
-
+
+
func _parse_location_object(value: Variant) -> Dictionary:
if typeof(value) != TYPE_DICTIONARY:
return {}
@@ -1726,32 +1910,32 @@ func _send_presence_heartbeat_async() -> void:
response.get("body", "")
])
_heartbeat_in_flight = false
-
-
-func _get_biome_material(tile: MeshInstance3D, biome_key: String) -> Material:
- var normalized_biome := biome_key if not biome_key.is_empty() else "plains"
- if _biome_materials.has(normalized_biome):
- return _biome_materials[normalized_biome]
-
- var source_material := tile.get_active_material(0)
- if source_material is StandardMaterial3D:
- var material := (source_material as StandardMaterial3D).duplicate() as StandardMaterial3D
- material.albedo_color = _get_biome_color(normalized_biome)
- _biome_materials[normalized_biome] = material
- return material
-
- return source_material
-
-
-func _get_biome_color(biome_key: String) -> Color:
- match biome_key:
- "forest":
- return Color(0.36, 0.62, 0.34, 1.0)
- "wetlands":
- return Color(0.28, 0.52, 0.44, 1.0)
- "rocky":
- return Color(0.52, 0.50, 0.44, 1.0)
- "desert":
- return Color(0.76, 0.67, 0.38, 1.0)
- _:
- return Color(0.56, 0.72, 0.38, 1.0)
+
+
+func _get_biome_material(tile: MeshInstance3D, biome_key: String) -> Material:
+ var normalized_biome := biome_key if not biome_key.is_empty() else "plains"
+ if _biome_materials.has(normalized_biome):
+ return _biome_materials[normalized_biome]
+
+ var source_material := tile.get_active_material(0)
+ if source_material is StandardMaterial3D:
+ var material := (source_material as StandardMaterial3D).duplicate() as StandardMaterial3D
+ material.albedo_color = _get_biome_color(normalized_biome)
+ _biome_materials[normalized_biome] = material
+ return material
+
+ return source_material
+
+
+func _get_biome_color(biome_key: String) -> Color:
+ match biome_key:
+ "forest":
+ return Color(0.36, 0.62, 0.34, 1.0)
+ "wetlands":
+ return Color(0.28, 0.52, 0.44, 1.0)
+ "rocky":
+ return Color(0.52, 0.50, 0.44, 1.0)
+ "desert":
+ return Color(0.76, 0.67, 0.38, 1.0)
+ _:
+ return Color(0.56, 0.72, 0.38, 1.0)
diff --git a/game/scenes/Levels/location_level.tscn b/game/scenes/Levels/location_level.tscn
index c7b04ec..4955328 100644
--- a/game/scenes/Levels/location_level.tscn
+++ b/game/scenes/Levels/location_level.tscn
@@ -165,6 +165,26 @@ layout_mode = 2
size_flags_vertical = 3
select_mode = 0
+[node name="RecipesPanel" type="PanelContainer" parent="InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+
+[node name="VBoxContainer" type="VBoxContainer" parent="InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/RecipesPanel"]
+layout_mode = 2
+theme_override_constants/separation = 8
+
+[node name="RecipesLabel" type="Label" parent="InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/RecipesPanel/VBoxContainer"]
+layout_mode = 2
+text = "Crafting"
+horizontal_alignment = 1
+
+[node name="RecipesList" type="ItemList" parent="InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/RecipesPanel/VBoxContainer"]
+custom_minimum_size = Vector2(0, 240)
+layout_mode = 2
+size_flags_vertical = 3
+select_mode = 0
+
[node name="ControlsPanel" type="PanelContainer" parent="InventoryMenu/MarginContainer/Panel/VBoxContainer"]
layout_mode = 2
@@ -220,6 +240,11 @@ layout_mode = 2
theme = ExtResource("4_button_theme")
text = "PICK UP"
+[node name="CraftButton" type="Button" parent="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow"]
+layout_mode = 2
+theme = ExtResource("4_button_theme")
+text = "CRAFT"
+
[node name="RefreshButton" type="Button" parent="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow"]
layout_mode = 2
theme = ExtResource("4_button_theme")
@@ -377,9 +402,11 @@ text = ""
[connection signal="pressed" from="PauseMenu/CenterContainer/Panel/VBoxContainer/MainMenuButton" to="." method="_on_pause_main_menu_pressed"]
[connection signal="item_selected" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/CharacterPanel/VBoxContainer/CharacterItems" to="." method="_on_character_items_selected"]
[connection signal="item_selected" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/GroundPanel/VBoxContainer/GroundItems" to="." method="_on_ground_items_selected"]
+[connection signal="item_selected" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/Columns/RecipesPanel/VBoxContainer/RecipesList" to="." method="_on_recipe_selected"]
[connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/MoveButton" to="." method="_on_inventory_move_pressed"]
[connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/DropButton" to="." method="_on_inventory_drop_pressed"]
[connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/PickupButton" to="." method="_on_inventory_pickup_pressed"]
+[connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/CraftButton" to="." method="_on_inventory_craft_pressed"]
[connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/RefreshButton" to="." method="_on_inventory_refresh_pressed"]
[connection signal="pressed" from="InventoryMenu/MarginContainer/Panel/VBoxContainer/ControlsPanel/VBoxContainer/ActionRow/CloseButton" to="." method="_on_inventory_close_pressed"]
[connection signal="item_selected" from="MailMenu/MarginContainer/Panel/VBoxContainer/Columns/InboxPanel/VBoxContainer/InboxItems" to="." method="_on_mail_inbox_selected"]
diff --git a/game/scenes/Levels/repo_bot_level.gd b/game/scenes/Levels/repo_bot_level.gd
new file mode 100644
index 0000000..5ccad12
--- /dev/null
+++ b/game/scenes/Levels/repo_bot_level.gd
@@ -0,0 +1,77 @@
+extends Node3D
+
+@export var player_spawn_position := Vector3(0.0, 0.0, 0.0)
+@export var day_length := 120.0
+@export var start_light_angle := -90.0
+
+@onready var _player: RigidBody3D = get_node_or_null("Player") as RigidBody3D
+@onready var _sun: DirectionalLight3D = $DirectionalLight3D
+@onready var _quest_text: RichTextLabel = get_node_or_null("PhoneUI/Control/PhoneFrame/QuestText") as RichTextLabel
+
+var _time := 0.0
+
+
+func _ready() -> void:
+ _move_player_to_spawn()
+ _setup_quest_ui()
+
+
+func _process(delta: float) -> void:
+ _update_day_night(delta)
+
+
+func _move_player_to_spawn() -> void:
+ if _player == null:
+ return
+ var spawn_marker := _consume_spawn_marker()
+ if spawn_marker != null:
+ _player.call("teleport_to_spawn", spawn_marker.global_transform)
+ else:
+ _player.global_position = player_spawn_position
+ _player.linear_velocity = Vector3.ZERO
+ _player.angular_velocity = Vector3.ZERO
+
+
+func _consume_spawn_marker() -> Node3D:
+ if TeleportState == null:
+ return null
+ var spawn_name: StringName = TeleportState.consume_spawn_name()
+ if spawn_name == StringName():
+ return null
+ return find_child(String(spawn_name), true, false) as Node3D
+
+
+func _update_day_night(delta: float) -> void:
+ if _sun == null or day_length <= 0.0:
+ return
+ _time = fmod(_time + delta, day_length)
+ var t: float = _time / day_length
+ var angle: float = lerp(start_light_angle, start_light_angle + 360.0, t)
+ _sun.rotation_degrees.x = angle
+ var energy_curve: float = -sin((t * TAU) + (start_light_angle * PI / 180.0))
+ _sun.light_energy = clamp((energy_curve * 1.0) + 0.2, 0.0, 1.2)
+
+
+func _setup_quest_ui() -> void:
+ if QuestManager == null:
+ return
+ if not QuestManager.is_connected("quest_state_changed", Callable(self, "_refresh_quest_ui")):
+ QuestManager.quest_state_changed.connect(_refresh_quest_ui)
+ _refresh_quest_ui()
+
+
+func _refresh_quest_ui() -> void:
+ if _quest_text == null or QuestManager == null:
+ return
+ var state: Dictionary = QuestManager.get_active_quest_state()
+ if not bool(state.get("active", false)):
+ _quest_text.text = "No active quest."
+ return
+ var title := String(state.get("title", "Quest"))
+ if bool(state.get("completed", false)):
+ _quest_text.text = "[b]%s[/b]\nComplete." % title
+ return
+ var step_index: int = int(state.get("current_step_index", 0))
+ var total_steps: int = int(state.get("total_steps", 0))
+ var step_text := String(state.get("current_step_text", ""))
+ _quest_text.text = "[b]%s[/b]\nStep %d/%d\n%s" % [title, step_index + 1, total_steps, step_text]
diff --git a/game/scenes/Levels/repo_bot_level.gd.uid b/game/scenes/Levels/repo_bot_level.gd.uid
new file mode 100644
index 0000000..b034e1a
--- /dev/null
+++ b/game/scenes/Levels/repo_bot_level.gd.uid
@@ -0,0 +1 @@
+uid://cy2vwcmtqsr4o
diff --git a/game/scenes/Levels/repo_bot_level.tscn b/game/scenes/Levels/repo_bot_level.tscn
new file mode 100644
index 0000000..d0bdc0b
--- /dev/null
+++ b/game/scenes/Levels/repo_bot_level.tscn
@@ -0,0 +1,135 @@
+[gd_scene load_steps=11 format=3]
+
+[ext_resource type="Script" path="res://scenes/Levels/repo_bot_level.gd" id="1_level"]
+[ext_resource type="Script" path="res://scenes/player.gd" id="2_player"]
+[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="3_model"]
+[ext_resource type="PackedScene" path="res://scenes/Interaction/prototype_gateway.tscn" id="4_teleporter"]
+[ext_resource type="Material" path="res://assets/materials/kenney_prototype_ground_green.tres" id="5_ground_mat"]
+[ext_resource type="PackedScene" uid="uid://dp6jk0k3o4v1u" path="res://scenes/UI/pause_menu.tscn" id="6_pause_menu"]
+[ext_resource type="PackedScene" path="res://scenes/Characters/repo_bot.tscn" id="7_repo_bot"]
+[ext_resource type="PackedScene" uid="uid://bnwpu7p8sbsfa" path="res://scenes/Interaction/RadialCommandMenu.tscn" id="8_radial_menu"]
+
+[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
+size = Vector3(1080, 2, 1080)
+
+[sub_resource type="BoxMesh" id="BoxMesh_ground"]
+material = ExtResource("5_ground_mat")
+size = Vector3(1080, 2, 1080)
+
+[node name="RepoBotLevel" type="Node3D"]
+script = ExtResource("1_level")
+
+[node name="Ground" type="StaticBody3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
+shape = SubResource("BoxShape3D_ground")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
+mesh = SubResource("BoxMesh_ground")
+
+[node name="Player" type="RigidBody3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
+script = ExtResource("2_player")
+camera_path = NodePath("Camera3D")
+phone_path = NodePath("../PhoneUI")
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
+shape = SubResource("SphereShape3D_player")
+
+[node name="TestCharAnimated" parent="Player" instance=ExtResource("3_model")]
+transform = Transform3D(-0.9998549, 0, 0.01703362, 0, 1, 0, -0.01703362, 0, -0.9998549, 0, 0, 0)
+
+[node name="Camera3D" type="Camera3D" parent="Player"]
+transform = Transform3D(0.9989785, -4.651856e-10, -0.045188628, 0.006969331, 0.9880354, 0.15407, 0.044647958, -0.15422754, 0.9870261, 0.22036135, 1.8988357, 0.64972365)
+current = true
+fov = 49.0
+
+[node name="SpotLight3D" type="SpotLight3D" parent="Player"]
+transform = Transform3D(1, 0, 0, 0, 0.906308, -0.422618, 0, 0.422618, 0.906308, 0, 1.7, -0.35)
+visible = false
+spot_range = 30.0
+spot_angle = 25.0
+
+[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0)
+shadow_enabled = true
+
+[node name="EntrySpawn" type="Marker3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2)
+
+[node name="RepoBot" parent="." instance=ExtResource("7_repo_bot")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -4)
+look_target_path = NodePath("../Player")
+
+[node name="Label3D" type="Label3D" parent="RepoBot"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 2.0, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "REPO BOT"
+
+[node name="ReturnTeleporter" parent="." instance=ExtResource("4_teleporter")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 5.5)
+target_scene_path = "res://scenes/Levels/level.tscn"
+target_spawn_name = &"RepoBotReturnSpawn"
+
+[node name="Label3D" type="Label3D" parent="ReturnTeleporter"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "PLAYGROUND"
+
+[node name="PauseMenu" parent="." instance=ExtResource("6_pause_menu")]
+
+[node name="PhoneUI" type="CanvasLayer" parent="."]
+layer = 5
+visible = false
+
+[node name="Control" type="Control" parent="PhoneUI"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -180.0
+offset_top = -320.0
+offset_right = 180.0
+offset_bottom = 320.0
+grow_horizontal = 2
+grow_vertical = 2
+color = Color(0.08, 0.08, 0.1, 1)
+
+[node name="QuestTitle" type="Label" parent="PhoneUI/Control/PhoneFrame"]
+layout_mode = 0
+offset_left = 18.0
+offset_top = 18.0
+offset_right = 150.0
+offset_bottom = 41.0
+text = "Quest Log"
+
+[node name="QuestText" type="RichTextLabel" parent="PhoneUI/Control/PhoneFrame"]
+layout_mode = 0
+offset_left = 18.0
+offset_top = 52.0
+offset_right = 344.0
+offset_bottom = 613.0
+bbcode_enabled = true
+text = "No active quest."
+scroll_active = false
+
+[node name="RadialCommandMenu" parent="PhoneUI/Control" instance=ExtResource("8_radial_menu")]
+layout_mode = 1
diff --git a/game/scenes/Levels/transportation_level.gd b/game/scenes/Levels/transportation_level.gd
index c6d89cd..5ccad12 100644
--- a/game/scenes/Levels/transportation_level.gd
+++ b/game/scenes/Levels/transportation_level.gd
@@ -6,12 +6,14 @@ extends Node3D
@onready var _player: RigidBody3D = get_node_or_null("Player") as RigidBody3D
@onready var _sun: DirectionalLight3D = $DirectionalLight3D
+@onready var _quest_text: RichTextLabel = get_node_or_null("PhoneUI/Control/PhoneFrame/QuestText") as RichTextLabel
var _time := 0.0
func _ready() -> void:
_move_player_to_spawn()
+ _setup_quest_ui()
func _process(delta: float) -> void:
@@ -21,11 +23,24 @@ func _process(delta: float) -> void:
func _move_player_to_spawn() -> void:
if _player == null:
return
- _player.global_position = player_spawn_position
+ var spawn_marker := _consume_spawn_marker()
+ if spawn_marker != null:
+ _player.call("teleport_to_spawn", spawn_marker.global_transform)
+ else:
+ _player.global_position = player_spawn_position
_player.linear_velocity = Vector3.ZERO
_player.angular_velocity = Vector3.ZERO
+func _consume_spawn_marker() -> Node3D:
+ if TeleportState == null:
+ return null
+ var spawn_name: StringName = TeleportState.consume_spawn_name()
+ if spawn_name == StringName():
+ return null
+ return find_child(String(spawn_name), true, false) as Node3D
+
+
func _update_day_night(delta: float) -> void:
if _sun == null or day_length <= 0.0:
return
@@ -35,3 +50,28 @@ func _update_day_night(delta: float) -> void:
_sun.rotation_degrees.x = angle
var energy_curve: float = -sin((t * TAU) + (start_light_angle * PI / 180.0))
_sun.light_energy = clamp((energy_curve * 1.0) + 0.2, 0.0, 1.2)
+
+
+func _setup_quest_ui() -> void:
+ if QuestManager == null:
+ return
+ if not QuestManager.is_connected("quest_state_changed", Callable(self, "_refresh_quest_ui")):
+ QuestManager.quest_state_changed.connect(_refresh_quest_ui)
+ _refresh_quest_ui()
+
+
+func _refresh_quest_ui() -> void:
+ if _quest_text == null or QuestManager == null:
+ return
+ var state: Dictionary = QuestManager.get_active_quest_state()
+ if not bool(state.get("active", false)):
+ _quest_text.text = "No active quest."
+ return
+ var title := String(state.get("title", "Quest"))
+ if bool(state.get("completed", false)):
+ _quest_text.text = "[b]%s[/b]\nComplete." % title
+ return
+ var step_index: int = int(state.get("current_step_index", 0))
+ var total_steps: int = int(state.get("total_steps", 0))
+ var step_text := String(state.get("current_step_text", ""))
+ _quest_text.text = "[b]%s[/b]\nStep %d/%d\n%s" % [title, step_index + 1, total_steps, step_text]
diff --git a/game/scenes/Levels/transportation_level.tscn b/game/scenes/Levels/transportation_level.tscn
index 9820617..f3c2728 100644
--- a/game/scenes/Levels/transportation_level.tscn
+++ b/game/scenes/Levels/transportation_level.tscn
@@ -1,4 +1,4 @@
-[gd_scene load_steps=9 format=3 uid="uid://b7p7k1i4t0m2l"]
+[gd_scene load_steps=15 format=3 uid="uid://b7p7k1i4t0m2l"]
[ext_resource type="Script" path="res://scenes/Levels/transportation_level.gd" id="1_6y4q1"]
[ext_resource type="Script" path="res://scenes/player.gd" id="2_player"]
@@ -6,6 +6,10 @@
[ext_resource type="PackedScene" path="res://scenes/Interaction/prototype_gateway.tscn" id="4_teleporter"]
[ext_resource type="Material" path="res://assets/materials/kenney_prototype_ground_green.tres" id="5_ground_mat"]
[ext_resource type="PackedScene" uid="uid://dp6jk0k3o4v1u" path="res://scenes/UI/pause_menu.tscn" id="6_pause_menu"]
+[ext_resource type="PackedScene" path="res://scenes/Vehicles/car.tscn" id="7_car"]
+[ext_resource type="Script" uid="uid://cshtdpjp4xy2f" path="res://scenes/Quests/quest_trigger_area.gd" id="8_qtrigger"]
+[ext_resource type="Material" path="res://assets/materials/kenney_prototype_prop_red.tres" id="9_checkpoint_mat"]
+[ext_resource type="PackedScene" uid="uid://bnwpu7p8sbsfa" path="res://scenes/Interaction/RadialCommandMenu.tscn" id="10_radial_menu"]
[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
@@ -16,6 +20,13 @@ size = Vector3(1080, 2, 1080)
material = ExtResource("5_ground_mat")
size = Vector3(1080, 2, 1080)
+[sub_resource type="SphereShape3D" id="SphereShape3D_checkpoint"]
+
+[sub_resource type="SphereMesh" id="SphereMesh_checkpoint"]
+material = ExtResource("9_checkpoint_mat")
+radius = 1.2
+height = 2.4
+
[node name="TransportationLevel" type="Node3D"]
script = ExtResource("1_6y4q1")
@@ -33,6 +44,7 @@ mesh = SubResource("BoxMesh_ground")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
script = ExtResource("2_player")
camera_path = NodePath("Camera3D")
+phone_path = NodePath("../PhoneUI")
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
@@ -56,8 +68,81 @@ spot_angle = 25.0
transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0)
shadow_enabled = true
+[node name="EntrySpawn" type="Marker3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2)
+
+[node name="Car" parent="." instance=ExtResource("7_car")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0, -3)
+
+[node name="QuestCheckpoint" type="Area3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0, -10)
+script = ExtResource("8_qtrigger")
+event_name = &"reach_checkpoint"
+target_group = &"vehicle"
+quest_id_filter = "first_drive"
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="QuestCheckpoint"]
+shape = SubResource("SphereShape3D_checkpoint")
+
+[node name="Visual" type="MeshInstance3D" parent="QuestCheckpoint"]
+mesh = SubResource("SphereMesh_checkpoint")
+
[node name="ReturnTeleporter" parent="." instance=ExtResource("4_teleporter")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5, 0, 0)
target_scene_path = "res://scenes/Levels/level.tscn"
+target_spawn_name = &"CarReturnSpawn"
+
+[node name="Label3D" type="Label3D" parent="ReturnTeleporter"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
+pixel_size = 0.012
+text = "PLAYGROUND"
[node name="PauseMenu" parent="." instance=ExtResource("6_pause_menu")]
+
+[node name="PhoneUI" type="CanvasLayer" parent="."]
+layer = 5
+visible = false
+
+[node name="Control" type="Control" parent="PhoneUI"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -180.0
+offset_top = -320.0
+offset_right = 180.0
+offset_bottom = 320.0
+grow_horizontal = 2
+grow_vertical = 2
+color = Color(0.08, 0.08, 0.1, 1)
+
+[node name="QuestTitle" type="Label" parent="PhoneUI/Control/PhoneFrame"]
+layout_mode = 0
+offset_left = 18.0
+offset_top = 18.0
+offset_right = 150.0
+offset_bottom = 41.0
+text = "Quest Log"
+
+[node name="QuestText" type="RichTextLabel" parent="PhoneUI/Control/PhoneFrame"]
+layout_mode = 0
+offset_left = 18.0
+offset_top = 52.0
+offset_right = 344.0
+offset_bottom = 613.0
+bbcode_enabled = true
+text = "No active quest."
+scroll_active = false
+
+[node name="RadialCommandMenu" parent="PhoneUI/Control" instance=ExtResource("10_radial_menu")]
+layout_mode = 1
diff --git a/game/scenes/Levels/trigger_zones_level.gd b/game/scenes/Levels/trigger_zones_level.gd
new file mode 100644
index 0000000..3e2cb5c
--- /dev/null
+++ b/game/scenes/Levels/trigger_zones_level.gd
@@ -0,0 +1,121 @@
+extends Node3D
+
+@export var player_spawn_position := Vector3(0.0, 0.0, 0.0)
+@export var day_length := 120.0
+@export var start_light_angle := -90.0
+
+@onready var _player: RigidBody3D = get_node_or_null("Player") as RigidBody3D
+@onready var _sun: DirectionalLight3D = $DirectionalLight3D
+@onready var _quest_text: RichTextLabel = get_node_or_null("PhoneUI/Control/PhoneFrame/QuestText") as RichTextLabel
+
+const FIRST_QUEST_ID := "first_drive"
+const QUEST_PROMPT_META_PREFIX := "quest_intro_prompt_shown_"
+const FIRST_QUEST := {
+ "id": FIRST_QUEST_ID,
+ "title": "RepoBot's First Task",
+ "description": "Get familiar with movement and vehicles.",
+ "steps": [
+ {
+ "id": "enter_car_feature",
+ "text": "Walk through the car teleporter.",
+ "complete_event": "entered_car_feature",
+ },
+ {
+ "id": "reach_checkpoint",
+ "text": "Drive the car into the checkpoint marker.",
+ "complete_event": "reach_checkpoint",
+ },
+ ],
+}
+
+var _time := 0.0
+
+
+func _ready() -> void:
+ _move_player_to_spawn()
+ _setup_quests()
+ _show_quest_intro_dialog()
+
+
+func _process(delta: float) -> void:
+ _update_day_night(delta)
+
+
+func _move_player_to_spawn() -> void:
+ if _player == null:
+ return
+ var spawn_marker := _consume_spawn_marker()
+ if spawn_marker != null:
+ _player.call("teleport_to_spawn", spawn_marker.global_transform)
+ else:
+ _player.global_position = player_spawn_position
+ _player.linear_velocity = Vector3.ZERO
+ _player.angular_velocity = Vector3.ZERO
+
+
+func _consume_spawn_marker() -> Node3D:
+ if TeleportState == null:
+ return null
+ var spawn_name: StringName = TeleportState.consume_spawn_name()
+ if spawn_name == StringName():
+ return null
+ return find_child(String(spawn_name), true, false) as Node3D
+
+
+func _update_day_night(delta: float) -> void:
+ if _sun == null or day_length <= 0.0:
+ return
+ _time = fmod(_time + delta, day_length)
+ var t: float = _time / day_length
+ var angle: float = lerp(start_light_angle, start_light_angle + 360.0, t)
+ _sun.rotation_degrees.x = angle
+ var energy_curve: float = -sin((t * TAU) + (start_light_angle * PI / 180.0))
+ _sun.light_energy = clamp((energy_curve * 1.0) + 0.2, 0.0, 1.2)
+
+
+func _setup_quests() -> void:
+ if QuestManager == null:
+ return
+ if not QuestManager.has_quest(FIRST_QUEST_ID):
+ QuestManager.register_quest(FIRST_QUEST)
+ if not QuestManager.is_active_quest(FIRST_QUEST_ID) and not QuestManager.is_quest_completed(FIRST_QUEST_ID):
+ QuestManager.start_quest(FIRST_QUEST_ID)
+ if not QuestManager.is_connected("quest_state_changed", Callable(self, "_refresh_quest_ui")):
+ QuestManager.quest_state_changed.connect(_refresh_quest_ui)
+ _refresh_quest_ui()
+
+
+func _refresh_quest_ui() -> void:
+ if _quest_text == null or QuestManager == null:
+ return
+ var state: Dictionary = QuestManager.get_active_quest_state()
+ if not bool(state.get("active", false)):
+ _quest_text.text = "No active quest."
+ return
+ var title := String(state.get("title", "Quest"))
+ if bool(state.get("completed", false)):
+ _quest_text.text = "[b]%s[/b]\nComplete." % title
+ return
+ var step_index: int = int(state.get("current_step_index", 0))
+ var total_steps: int = int(state.get("total_steps", 0))
+ var step_text := String(state.get("current_step_text", ""))
+ _quest_text.text = "[b]%s[/b]\nStep %d/%d\n%s" % [title, step_index + 1, total_steps, step_text]
+
+
+func _show_quest_intro_dialog() -> void:
+ if QuestManager == null:
+ return
+ var state: Dictionary = QuestManager.get_active_quest_state()
+ if not bool(state.get("active", false)) or bool(state.get("completed", false)):
+ return
+ var quest_id := String(state.get("quest_id", "")).strip_edges()
+ var step_id := String(state.get("current_step_id", "")).strip_edges()
+ var step_text := String(state.get("current_step_text", ""))
+ if quest_id.is_empty() or step_id.is_empty() or step_text.is_empty():
+ return
+ var prompt_key := "%s%s_%s" % [QUEST_PROMPT_META_PREFIX, quest_id, step_id]
+ if QuestManager.has_meta(prompt_key) and bool(QuestManager.get_meta(prompt_key)):
+ return
+ if DialogSystem and DialogSystem.has_method("show_text"):
+ DialogSystem.show_text("RepoBot: New task assigned.\n\n%s" % step_text)
+ QuestManager.set_meta(prompt_key, true)
diff --git a/game/scenes/Levels/trigger_zones_level.gd.uid b/game/scenes/Levels/trigger_zones_level.gd.uid
new file mode 100644
index 0000000..71b7c7d
--- /dev/null
+++ b/game/scenes/Levels/trigger_zones_level.gd.uid
@@ -0,0 +1 @@
+uid://br28dq2hwo7sc
diff --git a/game/scenes/Levels/trigger_zones_level.tscn b/game/scenes/Levels/trigger_zones_level.tscn
new file mode 100644
index 0000000..949df2c
--- /dev/null
+++ b/game/scenes/Levels/trigger_zones_level.tscn
@@ -0,0 +1,177 @@
+[gd_scene load_steps=14 format=3]
+
+[ext_resource type="Script" path="res://scenes/Levels/trigger_zones_level.gd" id="1_level"]
+[ext_resource type="Script" path="res://scenes/player.gd" id="2_player"]
+[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="3_model"]
+[ext_resource type="PackedScene" path="res://scenes/Interaction/prototype_gateway.tscn" id="4_teleporter"]
+[ext_resource type="Material" path="res://assets/materials/kenney_prototype_ground_green.tres" id="5_ground_mat"]
+[ext_resource type="PackedScene" uid="uid://dp6jk0k3o4v1u" path="res://scenes/UI/pause_menu.tscn" id="6_pause_menu"]
+[ext_resource type="Script" uid="uid://bk53njt7i3kmv" path="res://scenes/Interaction/dialog_trigger_area.gd" id="7_dialog"]
+[ext_resource type="PackedScene" uid="uid://bnwpu7p8sbsfa" path="res://scenes/Interaction/RadialCommandMenu.tscn" id="8_radial_menu"]
+
+[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
+size = Vector3(1080, 2, 1080)
+
+[sub_resource type="BoxMesh" id="BoxMesh_ground"]
+material = ExtResource("5_ground_mat")
+size = Vector3(1080, 2, 1080)
+
+[sub_resource type="SphereShape3D" id="SphereShape3D_dialog_zone"]
+radius = 2.5
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_dialog_zone"]
+transparency = 1
+cull_mode = 2
+shading_mode = 0
+albedo_color = Color(0.2, 0.8, 0.35, 0.18)
+
+[sub_resource type="SphereMesh" id="SphereMesh_dialog_zone"]
+material = SubResource("StandardMaterial3D_dialog_zone")
+radius = 2.5
+height = 5.0
+
+[node name="TriggerZonesLevel" type="Node3D"]
+script = ExtResource("1_level")
+
+[node name="Ground" type="StaticBody3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
+shape = SubResource("BoxShape3D_ground")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
+mesh = SubResource("BoxMesh_ground")
+
+[node name="Player" type="RigidBody3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
+script = ExtResource("2_player")
+camera_path = NodePath("Camera3D")
+phone_path = NodePath("../PhoneUI")
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
+shape = SubResource("SphereShape3D_player")
+
+[node name="TestCharAnimated" parent="Player" instance=ExtResource("3_model")]
+transform = Transform3D(-0.9998549, 0, 0.01703362, 0, 1, 0, -0.01703362, 0, -0.9998549, 0, 0, 0)
+
+[node name="Camera3D" type="Camera3D" parent="Player"]
+transform = Transform3D(0.9989785, -4.651856e-10, -0.045188628, 0.006969331, 0.9880354, 0.15407, 0.044647958, -0.15422754, 0.9870261, 0.22036135, 1.8988357, 0.64972365)
+current = true
+fov = 49.0
+
+[node name="SpotLight3D" type="SpotLight3D" parent="Player"]
+transform = Transform3D(1, 0, 0, 0, 0.906308, -0.422618, 0, 0.422618, 0.906308, 0, 1.7, -0.35)
+visible = false
+spot_range = 30.0
+spot_angle = 25.0
+
+[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0)
+shadow_enabled = true
+
+[node name="EntrySpawn" type="Marker3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2)
+
+[node name="ManualDialogZone" type="Area3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 4, 0, -3)
+script = ExtResource("7_dialog")
+prompt_text = "Press E to inspect area"
+dialog_text = "Manual dialog trigger zone"
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="ManualDialogZone"]
+shape = SubResource("SphereShape3D_dialog_zone")
+
+[node name="Visual" type="MeshInstance3D" parent="ManualDialogZone"]
+mesh = SubResource("SphereMesh_dialog_zone")
+
+[node name="Label3D" type="Label3D" parent="ManualDialogZone"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 3.0, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "PRESS E"
+
+[node name="AutoDialogZone" type="Area3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -4, 0, -3)
+script = ExtResource("7_dialog")
+dialog_text = "Auto dialog trigger zone"
+auto_popup = true
+auto_popup_close_on_exit = true
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="AutoDialogZone"]
+shape = SubResource("SphereShape3D_dialog_zone")
+
+[node name="Visual" type="MeshInstance3D" parent="AutoDialogZone"]
+mesh = SubResource("SphereMesh_dialog_zone")
+
+[node name="Label3D" type="Label3D" parent="AutoDialogZone"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 3.0, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "AUTO"
+
+[node name="ReturnTeleporter" parent="." instance=ExtResource("4_teleporter")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 5.5)
+target_scene_path = "res://scenes/Levels/level.tscn"
+target_spawn_name = &"TriggerZonesReturnSpawn"
+
+[node name="Label3D" type="Label3D" parent="ReturnTeleporter"]
+transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
+billboard = 1
+no_depth_test = true
+pixel_size = 0.012
+text = "PLAYGROUND"
+
+[node name="PauseMenu" parent="." instance=ExtResource("6_pause_menu")]
+
+[node name="PhoneUI" type="CanvasLayer" parent="."]
+layer = 5
+visible = false
+
+[node name="Control" type="Control" parent="PhoneUI"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control"]
+layout_mode = 1
+anchors_preset = 8
+anchor_left = 0.5
+anchor_top = 0.5
+anchor_right = 0.5
+anchor_bottom = 0.5
+offset_left = -180.0
+offset_top = -320.0
+offset_right = 180.0
+offset_bottom = 320.0
+grow_horizontal = 2
+grow_vertical = 2
+color = Color(0.08, 0.08, 0.1, 1)
+
+[node name="QuestTitle" type="Label" parent="PhoneUI/Control/PhoneFrame"]
+layout_mode = 0
+offset_left = 18.0
+offset_top = 18.0
+offset_right = 150.0
+offset_bottom = 41.0
+text = "Quest Log"
+
+[node name="QuestText" type="RichTextLabel" parent="PhoneUI/Control/PhoneFrame"]
+layout_mode = 0
+offset_left = 18.0
+offset_top = 52.0
+offset_right = 344.0
+offset_bottom = 613.0
+bbcode_enabled = true
+text = "No active quest."
+scroll_active = false
+
+[node name="RadialCommandMenu" parent="PhoneUI/Control" instance=ExtResource("8_radial_menu")]
+layout_mode = 1
diff --git a/game/scenes/UI/Settings.gd b/game/scenes/UI/Settings.gd
index 89d6b15..2b4d84b 100644
--- a/game/scenes/UI/Settings.gd
+++ b/game/scenes/UI/Settings.gd
@@ -1,112 +1,112 @@
-extends Node2D
-
-@onready var _tab_bar: TabBar = $MarginContainer/VBoxContainer/TabBar
-@onready var _tab_container: TabContainer = $MarginContainer/VBoxContainer/TabContainer
-@onready var _music_volume_slider: HSlider = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup/MusicVolumeSlider
-@onready var _music_volume_value: Label = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup/MusicVolumeValue
-@onready var _music_mute_checkbox: CheckBox = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup/MusicMuteCheckBox
-@onready var _sfx_volume_slider: HSlider = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup/SfxVolumeSlider
-@onready var _sfx_volume_value: Label = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup/SfxVolumeValue
-@onready var _sfx_mute_checkbox: CheckBox = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup/SfxMuteCheckBox
-@onready var _menu_music: AudioStreamPlayer = get_tree().get_root().get_node_or_null("MenuMusic")
-@onready var _menu_sfx: AudioStreamPlayer = get_tree().get_root().get_node_or_null("MenuSfx")
-
-func _ready() -> void:
- _tab_bar.tab_changed.connect(_on_tab_bar_tab_changed)
- _tab_container.tab_changed.connect(_on_tab_container_tab_changed)
- _tab_container.current_tab = _tab_bar.current_tab
- _music_volume_slider.value_changed.connect(_on_music_volume_changed)
- _music_mute_checkbox.toggled.connect(_on_music_mute_toggled)
- _sfx_volume_slider.value_changed.connect(_on_sfx_volume_changed)
- _sfx_mute_checkbox.toggled.connect(_on_sfx_mute_toggled)
- _sync_audio_controls()
- _register_focus_sounds()
-
-func _input(event):
- if event.is_action_pressed("ui_cancel"):
- get_tree().change_scene_to_file("uid://b4k81taauef4q")
-
-func _on_tab_bar_tab_changed(tab_index: int) -> void:
- if _tab_container.current_tab != tab_index:
- _tab_container.current_tab = tab_index
-
-func _on_tab_container_tab_changed(tab_index: int) -> void:
- if _tab_bar.current_tab != tab_index:
- _tab_bar.current_tab = tab_index
-
-func _sync_audio_controls() -> void:
- var value: float = 0.7
- var muted: bool = false
- if _menu_music:
- if _menu_music.has_method("get_user_volume"):
- value = _menu_music.get_user_volume()
- if _menu_music.has_method("is_user_muted"):
- muted = _menu_music.is_user_muted()
- _apply_audio_control_state(_music_volume_slider, _music_mute_checkbox, _music_volume_value, value, muted)
-
- var sfx_value: float = 0.7
- var sfx_muted: bool = false
- if _menu_sfx:
- if _menu_sfx.has_method("get_user_volume"):
- sfx_value = _menu_sfx.get_user_volume()
- if _menu_sfx.has_method("is_user_muted"):
- sfx_muted = _menu_sfx.is_user_muted()
- _apply_audio_control_state(_sfx_volume_slider, _sfx_mute_checkbox, _sfx_volume_value, sfx_value, sfx_muted)
-
-func _on_music_volume_changed(value: float) -> void:
- if _menu_music and _menu_music.has_method("set_user_volume"):
- _menu_music.set_user_volume(value)
- _update_volume_label(_music_volume_value, value, _music_mute_checkbox.button_pressed)
-
-func _on_music_mute_toggled(pressed: bool) -> void:
- if _menu_music and _menu_music.has_method("set_user_muted"):
- _menu_music.set_user_muted(pressed)
- _music_volume_slider.editable = not pressed
- _update_volume_label(_music_volume_value, _music_volume_slider.value, pressed)
-
-func _on_sfx_volume_changed(value: float) -> void:
- if _menu_sfx and _menu_sfx.has_method("set_user_volume"):
- _menu_sfx.set_user_volume(value)
- _update_volume_label(_sfx_volume_value, value, _sfx_mute_checkbox.button_pressed)
-
-func _on_sfx_mute_toggled(pressed: bool) -> void:
- if _menu_sfx and _menu_sfx.has_method("set_user_muted"):
- _menu_sfx.set_user_muted(pressed)
- _sfx_volume_slider.editable = not pressed
- _update_volume_label(_sfx_volume_value, _sfx_volume_slider.value, pressed)
-
-func _apply_audio_control_state(slider: HSlider, checkbox: CheckBox, value_label: Label, value: float, muted: bool) -> void:
- slider.set_block_signals(true)
- slider.value = value
- slider.set_block_signals(false)
- slider.editable = not muted
- checkbox.set_block_signals(true)
- checkbox.button_pressed = muted
- checkbox.set_block_signals(false)
- _update_volume_label(value_label, value, muted)
-
-func _update_volume_label(value_label: Label, value: float, muted: bool) -> void:
- if muted:
- value_label.text = "Muted"
- else:
- var percent: int = int(round(value * 100.0))
- value_label.text = str(percent) + "%"
-
-func _register_focus_sounds() -> void:
- _connect_focus_recursive(self)
-
-func _connect_focus_recursive(node: Node) -> void:
- if node is Control:
- var control: Control = node
- if control.focus_mode != Control.FOCUS_NONE:
- if not control.is_connected("focus_entered", Callable(self, "_on_menu_item_focus")):
- control.focus_entered.connect(_on_menu_item_focus)
- if control.has_signal("mouse_entered") and (control is BaseButton or control.focus_mode != Control.FOCUS_NONE):
- if not control.is_connected("mouse_entered", Callable(self, "_on_menu_item_focus")):
- control.mouse_entered.connect(_on_menu_item_focus)
- for child in node.get_children():
- _connect_focus_recursive(child)
-
-func _on_menu_item_focus() -> void:
- if MenuSfx:
- MenuSfx.play_hover()
+extends Node2D
+
+@onready var _tab_bar: TabBar = $MarginContainer/VBoxContainer/TabBar
+@onready var _tab_container: TabContainer = $MarginContainer/VBoxContainer/TabContainer
+@onready var _music_volume_slider: HSlider = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup/MusicVolumeSlider
+@onready var _music_volume_value: Label = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup/MusicVolumeValue
+@onready var _music_mute_checkbox: CheckBox = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup/MusicMuteCheckBox
+@onready var _sfx_volume_slider: HSlider = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup/SfxVolumeSlider
+@onready var _sfx_volume_value: Label = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup/SfxVolumeValue
+@onready var _sfx_mute_checkbox: CheckBox = $MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup/SfxMuteCheckBox
+@onready var _menu_music: AudioStreamPlayer = get_tree().get_root().get_node_or_null("MenuMusic")
+@onready var _menu_sfx: AudioStreamPlayer = get_tree().get_root().get_node_or_null("MenuSfx")
+
+func _ready() -> void:
+ _tab_bar.tab_changed.connect(_on_tab_bar_tab_changed)
+ _tab_container.tab_changed.connect(_on_tab_container_tab_changed)
+ _tab_container.current_tab = _tab_bar.current_tab
+ _music_volume_slider.value_changed.connect(_on_music_volume_changed)
+ _music_mute_checkbox.toggled.connect(_on_music_mute_toggled)
+ _sfx_volume_slider.value_changed.connect(_on_sfx_volume_changed)
+ _sfx_mute_checkbox.toggled.connect(_on_sfx_mute_toggled)
+ _sync_audio_controls()
+ _register_focus_sounds()
+
+func _input(event):
+ if event.is_action_pressed("ui_cancel"):
+ get_tree().change_scene_to_file("uid://b4k81taauef4q")
+
+func _on_tab_bar_tab_changed(tab_index: int) -> void:
+ if _tab_container.current_tab != tab_index:
+ _tab_container.current_tab = tab_index
+
+func _on_tab_container_tab_changed(tab_index: int) -> void:
+ if _tab_bar.current_tab != tab_index:
+ _tab_bar.current_tab = tab_index
+
+func _sync_audio_controls() -> void:
+ var value: float = 0.7
+ var muted: bool = false
+ if _menu_music:
+ if _menu_music.has_method("get_user_volume"):
+ value = _menu_music.get_user_volume()
+ if _menu_music.has_method("is_user_muted"):
+ muted = _menu_music.is_user_muted()
+ _apply_audio_control_state(_music_volume_slider, _music_mute_checkbox, _music_volume_value, value, muted)
+
+ var sfx_value: float = 0.7
+ var sfx_muted: bool = false
+ if _menu_sfx:
+ if _menu_sfx.has_method("get_user_volume"):
+ sfx_value = _menu_sfx.get_user_volume()
+ if _menu_sfx.has_method("is_user_muted"):
+ sfx_muted = _menu_sfx.is_user_muted()
+ _apply_audio_control_state(_sfx_volume_slider, _sfx_mute_checkbox, _sfx_volume_value, sfx_value, sfx_muted)
+
+func _on_music_volume_changed(value: float) -> void:
+ if _menu_music and _menu_music.has_method("set_user_volume"):
+ _menu_music.set_user_volume(value)
+ _update_volume_label(_music_volume_value, value, _music_mute_checkbox.button_pressed)
+
+func _on_music_mute_toggled(pressed: bool) -> void:
+ if _menu_music and _menu_music.has_method("set_user_muted"):
+ _menu_music.set_user_muted(pressed)
+ _music_volume_slider.editable = not pressed
+ _update_volume_label(_music_volume_value, _music_volume_slider.value, pressed)
+
+func _on_sfx_volume_changed(value: float) -> void:
+ if _menu_sfx and _menu_sfx.has_method("set_user_volume"):
+ _menu_sfx.set_user_volume(value)
+ _update_volume_label(_sfx_volume_value, value, _sfx_mute_checkbox.button_pressed)
+
+func _on_sfx_mute_toggled(pressed: bool) -> void:
+ if _menu_sfx and _menu_sfx.has_method("set_user_muted"):
+ _menu_sfx.set_user_muted(pressed)
+ _sfx_volume_slider.editable = not pressed
+ _update_volume_label(_sfx_volume_value, _sfx_volume_slider.value, pressed)
+
+func _apply_audio_control_state(slider: HSlider, checkbox: CheckBox, value_label: Label, value: float, muted: bool) -> void:
+ slider.set_block_signals(true)
+ slider.value = value
+ slider.set_block_signals(false)
+ slider.editable = not muted
+ checkbox.set_block_signals(true)
+ checkbox.button_pressed = muted
+ checkbox.set_block_signals(false)
+ _update_volume_label(value_label, value, muted)
+
+func _update_volume_label(value_label: Label, value: float, muted: bool) -> void:
+ if muted:
+ value_label.text = "Muted"
+ else:
+ var percent: int = int(round(value * 100.0))
+ value_label.text = str(percent) + "%"
+
+func _register_focus_sounds() -> void:
+ _connect_focus_recursive(self)
+
+func _connect_focus_recursive(node: Node) -> void:
+ if node is Control:
+ var control: Control = node
+ if control.focus_mode != Control.FOCUS_NONE:
+ if not control.is_connected("focus_entered", Callable(self, "_on_menu_item_focus")):
+ control.focus_entered.connect(_on_menu_item_focus)
+ if control.has_signal("mouse_entered") and (control is BaseButton or control.focus_mode != Control.FOCUS_NONE):
+ if not control.is_connected("mouse_entered", Callable(self, "_on_menu_item_focus")):
+ control.mouse_entered.connect(_on_menu_item_focus)
+ for child in node.get_children():
+ _connect_focus_recursive(child)
+
+func _on_menu_item_focus() -> void:
+ if MenuSfx:
+ MenuSfx.play_hover()
diff --git a/game/scenes/UI/Settings.tscn b/game/scenes/UI/Settings.tscn
index 01f165b..f325614 100644
--- a/game/scenes/UI/Settings.tscn
+++ b/game/scenes/UI/Settings.tscn
@@ -1,157 +1,157 @@
-[gd_scene load_steps=5 format=3 uid="uid://d3tqrm4ry4l88"]
-
-[ext_resource type="Script" uid="uid://h1slqbemgwvr" path="res://scenes/UI/Settings.gd" id="1_1dggd"]
-[ext_resource type="Texture2D" uid="uid://dhuosr0p605gj" path="res://assets/images/pp_start_bg.png" id="1_i47rn"]
-[ext_resource type="FontFile" uid="uid://m5ceou0rk6j6" path="res://assets/fonts/PlayfairDisplay-VariableFont_wght.ttf" id="2_46duy"]
-[ext_resource type="Theme" uid="uid://wpxmub0n2dr3" path="res://themes/button_theme.tres" id="3_46duy"]
-
-
-[node name="settings_screen" type="Node2D"]
-script = ExtResource("1_1dggd")
-metadata/_edit_vertical_guides_ = [1152.0]
-
-[node name="TextureRect" type="TextureRect" parent="."]
-offset_left = -192.0
-offset_top = -188.0
-offset_right = 1344.0
-offset_bottom = 836.0
-texture = ExtResource("1_i47rn")
-
-[node name="MarginContainer" type="MarginContainer" parent="."]
-offset_right = 1152.0
-offset_bottom = 648.0
-
-[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
-layout_mode = 2
-
-[node name="Label" type="Label" parent="MarginContainer/VBoxContainer"]
-layout_mode = 2
-theme_override_fonts/font = ExtResource("2_46duy")
-theme_override_font_sizes/font_size = 42
-text = "Settings"
-horizontal_alignment = 1
-
-[node name="TabBar" type="TabBar" parent="MarginContainer/VBoxContainer"]
-layout_mode = 2
-theme = ExtResource("3_46duy")
-current_tab = 0
-tab_count = 4
-tab_0/title = "Gameplay"
-tab_1/title = "Video"
-tab_2/title = "Audio"
-tab_3/title = "Dev"
-
-[node name="TabContainer" type="TabContainer" parent="MarginContainer/VBoxContainer"]
-layout_mode = 2
-tabs_visible = false
-
-[node name="Gameplay" type="Control" parent="MarginContainer/VBoxContainer/TabContainer"]
-layout_mode = 3
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-
-[node name="Video" type="Control" parent="MarginContainer/VBoxContainer/TabContainer"]
-layout_mode = 3
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-
-[node name="Audio" type="Control" parent="MarginContainer/VBoxContainer/TabContainer"]
-layout_mode = 3
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-
-[node name="AudioVBox" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Audio"]
-layout_mode = 3
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-theme_override_constants/separation = 10
-offset_left = 120.0
-offset_top = 240.0
-offset_right = -120.0
-offset_bottom = -40.0
-
-[node name="MusicVolumeLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox"]
-layout_mode = 2
-text = "Music Volume"
-horizontal_alignment = 1
-size_flags_horizontal = 4
-
-[node name="MusicVolumeGroup" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme_override_constants/separation = 12
-alignment = 1
-custom_minimum_size = Vector2(0, 40)
-
-[node name="MusicVolumeSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup"]
-layout_mode = 2
-min_value = 0.0
-max_value = 1.0
-step = 0.01
-size_flags_horizontal = 3
-custom_minimum_size = Vector2(320, 0)
-
-[node name="MusicVolumeValue" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup"]
-layout_mode = 2
-text = "70%"
-size_flags_horizontal = 1
-horizontal_alignment = 1
-custom_minimum_size = Vector2(60, 0)
-
-[node name="MusicMuteCheckBox" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup"]
-layout_mode = 2
-text = "Mute"
-size_flags_horizontal = 1
-
-[node name="SfxVolumeLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox"]
-layout_mode = 2
-text = "Menu SFX Volume"
-horizontal_alignment = 1
-size_flags_horizontal = 4
-
-[node name="SfxVolumeGroup" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme_override_constants/separation = 12
-alignment = 1
-custom_minimum_size = Vector2(0, 40)
-
-[node name="SfxVolumeSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup"]
-layout_mode = 2
-min_value = 0.0
-max_value = 1.0
-step = 0.01
-size_flags_horizontal = 3
-custom_minimum_size = Vector2(320, 0)
-
-[node name="SfxVolumeValue" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup"]
-layout_mode = 2
-text = "70%"
-size_flags_horizontal = 1
-horizontal_alignment = 1
-custom_minimum_size = Vector2(60, 0)
-
-[node name="SfxMuteCheckBox" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup"]
-layout_mode = 2
-text = "Mute"
-size_flags_horizontal = 1
-
-[node name="Dev" type="Control" parent="MarginContainer/VBoxContainer/TabContainer"]
-layout_mode = 3
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
+[gd_scene load_steps=5 format=3 uid="uid://d3tqrm4ry4l88"]
+
+[ext_resource type="Script" uid="uid://h1slqbemgwvr" path="res://scenes/UI/Settings.gd" id="1_1dggd"]
+[ext_resource type="Texture2D" uid="uid://dhuosr0p605gj" path="res://assets/images/pp_start_bg.png" id="1_i47rn"]
+[ext_resource type="FontFile" uid="uid://m5ceou0rk6j6" path="res://assets/fonts/PlayfairDisplay-VariableFont_wght.ttf" id="2_46duy"]
+[ext_resource type="Theme" uid="uid://wpxmub0n2dr3" path="res://themes/button_theme.tres" id="3_46duy"]
+
+
+[node name="settings_screen" type="Node2D"]
+script = ExtResource("1_1dggd")
+metadata/_edit_vertical_guides_ = [1152.0]
+
+[node name="TextureRect" type="TextureRect" parent="."]
+offset_left = -192.0
+offset_top = -188.0
+offset_right = 1344.0
+offset_bottom = 836.0
+texture = ExtResource("1_i47rn")
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+offset_right = 1152.0
+offset_bottom = 648.0
+
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
+layout_mode = 2
+
+[node name="Label" type="Label" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+theme_override_fonts/font = ExtResource("2_46duy")
+theme_override_font_sizes/font_size = 42
+text = "Settings"
+horizontal_alignment = 1
+
+[node name="TabBar" type="TabBar" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+theme = ExtResource("3_46duy")
+current_tab = 0
+tab_count = 4
+tab_0/title = "Gameplay"
+tab_1/title = "Video"
+tab_2/title = "Audio"
+tab_3/title = "Dev"
+
+[node name="TabContainer" type="TabContainer" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+tabs_visible = false
+
+[node name="Gameplay" type="Control" parent="MarginContainer/VBoxContainer/TabContainer"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="Video" type="Control" parent="MarginContainer/VBoxContainer/TabContainer"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="Audio" type="Control" parent="MarginContainer/VBoxContainer/TabContainer"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="AudioVBox" type="VBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Audio"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/separation = 10
+offset_left = 120.0
+offset_top = 240.0
+offset_right = -120.0
+offset_bottom = -40.0
+
+[node name="MusicVolumeLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox"]
+layout_mode = 2
+text = "Music Volume"
+horizontal_alignment = 1
+size_flags_horizontal = 4
+
+[node name="MusicVolumeGroup" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme_override_constants/separation = 12
+alignment = 1
+custom_minimum_size = Vector2(0, 40)
+
+[node name="MusicVolumeSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup"]
+layout_mode = 2
+min_value = 0.0
+max_value = 1.0
+step = 0.01
+size_flags_horizontal = 3
+custom_minimum_size = Vector2(320, 0)
+
+[node name="MusicVolumeValue" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup"]
+layout_mode = 2
+text = "70%"
+size_flags_horizontal = 1
+horizontal_alignment = 1
+custom_minimum_size = Vector2(60, 0)
+
+[node name="MusicMuteCheckBox" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/MusicVolumeGroup"]
+layout_mode = 2
+text = "Mute"
+size_flags_horizontal = 1
+
+[node name="SfxVolumeLabel" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox"]
+layout_mode = 2
+text = "Menu SFX Volume"
+horizontal_alignment = 1
+size_flags_horizontal = 4
+
+[node name="SfxVolumeGroup" type="HBoxContainer" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme_override_constants/separation = 12
+alignment = 1
+custom_minimum_size = Vector2(0, 40)
+
+[node name="SfxVolumeSlider" type="HSlider" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup"]
+layout_mode = 2
+min_value = 0.0
+max_value = 1.0
+step = 0.01
+size_flags_horizontal = 3
+custom_minimum_size = Vector2(320, 0)
+
+[node name="SfxVolumeValue" type="Label" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup"]
+layout_mode = 2
+text = "70%"
+size_flags_horizontal = 1
+horizontal_alignment = 1
+custom_minimum_size = Vector2(60, 0)
+
+[node name="SfxMuteCheckBox" type="CheckBox" parent="MarginContainer/VBoxContainer/TabContainer/Audio/AudioVBox/SfxVolumeGroup"]
+layout_mode = 2
+text = "Mute"
+size_flags_horizontal = 1
+
+[node name="Dev" type="Control" parent="MarginContainer/VBoxContainer/TabContainer"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
diff --git a/game/scenes/UI/auth_state.gd b/game/scenes/UI/auth_state.gd
index 1f0e03f..6f18171 100644
--- a/game/scenes/UI/auth_state.gd
+++ b/game/scenes/UI/auth_state.gd
@@ -1,15 +1,15 @@
-extends Node
-
-var is_logged_in: bool = false
-var access_token: String = ""
-var username: String = ""
-
-func set_session(new_username: String, token: String) -> void:
- is_logged_in = true
- username = new_username
- access_token = token
-
-func clear_session() -> void:
- is_logged_in = false
- username = ""
- access_token = ""
+extends Node
+
+var is_logged_in: bool = false
+var access_token: String = ""
+var username: String = ""
+
+func set_session(new_username: String, token: String) -> void:
+ is_logged_in = true
+ username = new_username
+ access_token = token
+
+func clear_session() -> void:
+ is_logged_in = false
+ username = ""
+ access_token = ""
diff --git a/game/scenes/UI/character_screen.gd b/game/scenes/UI/character_screen.gd
index 7723b95..da6a7c3 100644
--- a/game/scenes/UI/character_screen.gd
+++ b/game/scenes/UI/character_screen.gd
@@ -1,99 +1,99 @@
-extends Control
-
-const AUTH_LOGOUT_URL := "https://pauth.ranaze.com/api/Auth/logout"
-
-@onready var _status_label: Label = %StatusLabel
-@onready var _character_list: ItemList = %CharacterList
-@onready var _name_input: LineEdit = %NameInput
-@onready var _logout_request: HTTPRequest = %LogoutRequest
-
-var _characters: Array = []
-
-func _ready() -> void:
- if not AuthState.is_logged_in:
- get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
- return
- _load_characters()
-
-func _log_failure(action: String, response: Dictionary) -> void:
- push_warning("%s failed: status=%s error=%s body=%s" % [
- action,
- response.get("status", "n/a"),
- response.get("error", ""),
- response.get("body", "")
- ])
-
-func _load_characters() -> void:
- _status_label.text = "Loading characters..."
- var response := await CharacterService.list_characters()
- if not response.get("ok", false):
- _log_failure("List characters", response)
- _status_label.text = "Failed to load characters."
- return
-
- var parsed: Variant = JSON.parse_string(String(response.get("body", "")))
- if typeof(parsed) != TYPE_ARRAY:
- _status_label.text = "Unexpected character response."
- return
-
- _characters = parsed
- _character_list.clear()
- for character in _characters:
- var character_name := String(character.get("name", "Unnamed"))
- _character_list.add_item(character_name)
-
- if _characters.is_empty():
- _status_label.text = "No characters yet. Add one below."
- else:
- _status_label.text = ""
-
-func _on_add_button_pressed() -> void:
- var character_name := _name_input.text.strip_edges()
- if character_name.is_empty():
- _status_label.text = "Enter a character name first."
- return
-
- _status_label.text = "Creating character..."
- var response := await CharacterService.create_character(character_name)
- if not response.get("ok", false):
- _log_failure("Create character", response)
- _status_label.text = "Failed to create character."
- return
-
- var parsed: Variant = JSON.parse_string(String(response.get("body", "")))
- if typeof(parsed) == TYPE_DICTIONARY:
- _characters.append(parsed)
- _character_list.add_item(String(parsed.get("name", character_name)))
- _name_input.text = ""
- _status_label.text = "Character added."
- else:
- _status_label.text = "Character created, but response was unexpected."
-
+extends Control
+
+const AUTH_LOGOUT_URL := "https://pauth.ranaze.com/api/Auth/logout"
+
+@onready var _status_label: Label = %StatusLabel
+@onready var _character_list: ItemList = %CharacterList
+@onready var _name_input: LineEdit = %NameInput
+@onready var _logout_request: HTTPRequest = %LogoutRequest
+
+var _characters: Array = []
+
+func _ready() -> void:
+ if not AuthState.is_logged_in:
+ get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
+ return
+ _load_characters()
+
+func _log_failure(action: String, response: Dictionary) -> void:
+ push_warning("%s failed: status=%s error=%s body=%s" % [
+ action,
+ response.get("status", "n/a"),
+ response.get("error", ""),
+ response.get("body", "")
+ ])
+
+func _load_characters() -> void:
+ _status_label.text = "Loading characters..."
+ var response := await CharacterService.list_characters()
+ if not response.get("ok", false):
+ _log_failure("List characters", response)
+ _status_label.text = "Failed to load characters."
+ return
+
+ var parsed: Variant = JSON.parse_string(String(response.get("body", "")))
+ if typeof(parsed) != TYPE_ARRAY:
+ _status_label.text = "Unexpected character response."
+ return
+
+ _characters = parsed
+ _character_list.clear()
+ for character in _characters:
+ var character_name := String(character.get("name", "Unnamed"))
+ _character_list.add_item(character_name)
+
+ if _characters.is_empty():
+ _status_label.text = "No characters yet. Add one below."
+ else:
+ _status_label.text = ""
+
+func _on_add_button_pressed() -> void:
+ var character_name := _name_input.text.strip_edges()
+ if character_name.is_empty():
+ _status_label.text = "Enter a character name first."
+ return
+
+ _status_label.text = "Creating character..."
+ var response := await CharacterService.create_character(character_name)
+ if not response.get("ok", false):
+ _log_failure("Create character", response)
+ _status_label.text = "Failed to create character."
+ return
+
+ var parsed: Variant = JSON.parse_string(String(response.get("body", "")))
+ if typeof(parsed) == TYPE_DICTIONARY:
+ _characters.append(parsed)
+ _character_list.add_item(String(parsed.get("name", character_name)))
+ _name_input.text = ""
+ _status_label.text = "Character added."
+ else:
+ _status_label.text = "Character created, but response was unexpected."
+
func _on_delete_button_pressed() -> void:
var selected := _character_list.get_selected_items()
if selected.is_empty():
_status_label.text = "Select a character to delete."
return
-
- var index := selected[0]
- if index < 0 or index >= _characters.size():
- _status_label.text = "Invalid selection."
- return
-
- var character: Dictionary = _characters[index]
- var character_id := String(character.get("id", character.get("Id", "")))
- if character_id.is_empty():
- _status_label.text = "Missing character id."
- return
-
- _status_label.text = "Deleting character..."
- var response := await CharacterService.delete_character(character_id)
- if not response.get("ok", false):
- _log_failure("Delete character", response)
- _status_label.text = "Failed to delete character."
- return
-
- _characters.remove_at(index)
+
+ var index := selected[0]
+ if index < 0 or index >= _characters.size():
+ _status_label.text = "Invalid selection."
+ return
+
+ var character: Dictionary = _characters[index]
+ var character_id := String(character.get("id", character.get("Id", "")))
+ if character_id.is_empty():
+ _status_label.text = "Missing character id."
+ return
+
+ _status_label.text = "Deleting character..."
+ var response := await CharacterService.delete_character(character_id)
+ if not response.get("ok", false):
+ _log_failure("Delete character", response)
+ _status_label.text = "Failed to delete character."
+ return
+
+ _characters.remove_at(index)
_character_list.remove_item(index)
_status_label.text = "Character deleted."
@@ -111,34 +111,34 @@ func _on_select_button_pressed() -> void:
var character: Dictionary = _characters[index]
SelectedCharacter.set_character(character)
get_tree().change_scene_to_file("res://scenes/Levels/location_level.tscn")
-
-func _on_refresh_button_pressed() -> void:
- _load_characters()
-
-func _on_back_button_pressed() -> void:
- get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
-
-func _on_logout_button_pressed() -> void:
- _request_logout()
-
-func _request_logout() -> void:
- if AuthState.access_token.is_empty():
- AuthState.clear_session()
- get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
- return
-
- var headers := PackedStringArray([
- "Authorization: Bearer %s" % AuthState.access_token,
- ])
- var err := _logout_request.request(AUTH_LOGOUT_URL, headers, HTTPClient.METHOD_POST)
- if err != OK:
- push_warning("Failed to send logout request: %s" % err)
- AuthState.clear_session()
- get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
-
-func _on_logout_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
- var body_text := body.get_string_from_utf8()
- if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
- push_warning("Logout failed (%s/%s): %s" % [result, response_code, body_text])
- AuthState.clear_session()
- get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
+
+func _on_refresh_button_pressed() -> void:
+ _load_characters()
+
+func _on_back_button_pressed() -> void:
+ get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
+
+func _on_logout_button_pressed() -> void:
+ _request_logout()
+
+func _request_logout() -> void:
+ if AuthState.access_token.is_empty():
+ AuthState.clear_session()
+ get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
+ return
+
+ var headers := PackedStringArray([
+ "Authorization: Bearer %s" % AuthState.access_token,
+ ])
+ var err := _logout_request.request(AUTH_LOGOUT_URL, headers, HTTPClient.METHOD_POST)
+ if err != OK:
+ push_warning("Failed to send logout request: %s" % err)
+ AuthState.clear_session()
+ get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
+
+func _on_logout_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
+ var body_text := body.get_string_from_utf8()
+ if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
+ push_warning("Logout failed (%s/%s): %s" % [result, response_code, body_text])
+ AuthState.clear_session()
+ get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
diff --git a/game/scenes/UI/character_screen.tscn b/game/scenes/UI/character_screen.tscn
index 59e7a63..17611de 100644
--- a/game/scenes/UI/character_screen.tscn
+++ b/game/scenes/UI/character_screen.tscn
@@ -1,92 +1,92 @@
-[gd_scene load_steps=5 format=3 uid="uid://cw3b7n7k2c4h6"]
-
-[ext_resource type="Script" path="res://scenes/UI/character_screen.gd" id="1_0p3qk"]
-[ext_resource type="Texture2D" uid="uid://dhuosr0p605gj" path="res://assets/images/pp_start_bg.png" id="2_5g2t1"]
-[ext_resource type="Theme" uid="uid://tn8qndst18d6" path="res://themes/title_font_theme.tres" id="3_k2j6k"]
-[ext_resource type="Theme" uid="uid://wpxmub0n2dr3" path="res://themes/button_theme.tres" id="4_5b3b7"]
-
-[node name="CharacterScreen" 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_0p3qk")
-
-[node name="TextureRect" type="TextureRect" parent="."]
-layout_mode = 1
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-texture = ExtResource("2_5g2t1")
-
-[node name="MarginContainer" type="MarginContainer" parent="."]
-layout_mode = 1
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-theme_override_constants/margin_left = 80
-theme_override_constants/margin_top = 40
-theme_override_constants/margin_right = 80
-theme_override_constants/margin_bottom = 40
-
-[node name="ContentCenter" type="CenterContainer" parent="MarginContainer"]
-layout_mode = 1
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-
-[node name="ContentVBox" type="VBoxContainer" parent="MarginContainer/ContentCenter"]
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 4
-theme_override_constants/separation = 18
-
-[node name="TitleLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme = ExtResource("3_k2j6k")
-text = "YOUR CHARACTERS"
-horizontal_alignment = 1
-
-[node name="StatusLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox"]
-unique_name_in_owner = true
-layout_mode = 2
-size_flags_horizontal = 4
-horizontal_alignment = 1
-
-[node name="CharacterList" type="ItemList" parent="MarginContainer/ContentCenter/ContentVBox"]
-unique_name_in_owner = true
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 4
-custom_minimum_size = Vector2(520, 240)
-
-[node name="AddHBox" type="HBoxContainer" parent="MarginContainer/ContentCenter/ContentVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme_override_constants/separation = 10
-
-[node name="NameInput" type="LineEdit" parent="MarginContainer/ContentCenter/ContentVBox/AddHBox"]
-unique_name_in_owner = true
-layout_mode = 2
-size_flags_horizontal = 3
-placeholder_text = "character name"
-
-[node name="AddButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/AddHBox"]
-layout_mode = 2
-size_flags_horizontal = 0
-theme = ExtResource("4_5b3b7")
-text = "ADD"
-text_alignment = 1
-
+[gd_scene load_steps=5 format=3 uid="uid://cw3b7n7k2c4h6"]
+
+[ext_resource type="Script" path="res://scenes/UI/character_screen.gd" id="1_0p3qk"]
+[ext_resource type="Texture2D" uid="uid://dhuosr0p605gj" path="res://assets/images/pp_start_bg.png" id="2_5g2t1"]
+[ext_resource type="Theme" uid="uid://tn8qndst18d6" path="res://themes/title_font_theme.tres" id="3_k2j6k"]
+[ext_resource type="Theme" uid="uid://wpxmub0n2dr3" path="res://themes/button_theme.tres" id="4_5b3b7"]
+
+[node name="CharacterScreen" 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_0p3qk")
+
+[node name="TextureRect" type="TextureRect" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+texture = ExtResource("2_5g2t1")
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 80
+theme_override_constants/margin_top = 40
+theme_override_constants/margin_right = 80
+theme_override_constants/margin_bottom = 40
+
+[node name="ContentCenter" type="CenterContainer" parent="MarginContainer"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="ContentVBox" type="VBoxContainer" parent="MarginContainer/ContentCenter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme_override_constants/separation = 18
+
+[node name="TitleLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme = ExtResource("3_k2j6k")
+text = "YOUR CHARACTERS"
+horizontal_alignment = 1
+
+[node name="StatusLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 4
+horizontal_alignment = 1
+
+[node name="CharacterList" type="ItemList" parent="MarginContainer/ContentCenter/ContentVBox"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+custom_minimum_size = Vector2(520, 240)
+
+[node name="AddHBox" type="HBoxContainer" parent="MarginContainer/ContentCenter/ContentVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme_override_constants/separation = 10
+
+[node name="NameInput" type="LineEdit" parent="MarginContainer/ContentCenter/ContentVBox/AddHBox"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+placeholder_text = "character name"
+
+[node name="AddButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/AddHBox"]
+layout_mode = 2
+size_flags_horizontal = 0
+theme = ExtResource("4_5b3b7")
+text = "ADD"
+text_alignment = 1
+
[node name="ActionHBox" type="HBoxContainer" parent="MarginContainer/ContentCenter/ContentVBox"]
layout_mode = 2
size_flags_horizontal = 4
@@ -104,36 +104,36 @@ layout_mode = 2
size_flags_horizontal = 4
theme = ExtResource("4_5b3b7")
text = "REFRESH"
-text_alignment = 1
-
-[node name="DeleteButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ActionHBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme = ExtResource("4_5b3b7")
-text = "DELETE"
-text_alignment = 1
-
-[node name="BackButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ActionHBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme = ExtResource("4_5b3b7")
-text = "BACK"
-text_alignment = 1
-
-[node name="LogoutButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ActionHBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme = ExtResource("4_5b3b7")
-text = "LOG OUT"
-text_alignment = 1
-
-[node name="LogoutRequest" type="HTTPRequest" parent="."]
-unique_name_in_owner = true
-
+text_alignment = 1
+
+[node name="DeleteButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ActionHBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme = ExtResource("4_5b3b7")
+text = "DELETE"
+text_alignment = 1
+
+[node name="BackButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ActionHBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme = ExtResource("4_5b3b7")
+text = "BACK"
+text_alignment = 1
+
+[node name="LogoutButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ActionHBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme = ExtResource("4_5b3b7")
+text = "LOG OUT"
+text_alignment = 1
+
+[node name="LogoutRequest" type="HTTPRequest" parent="."]
+unique_name_in_owner = true
+
[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/AddHBox/AddButton" to="." method="_on_add_button_pressed"]
[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ActionHBox/SelectButton" to="." method="_on_select_button_pressed"]
[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ActionHBox/RefreshButton" to="." method="_on_refresh_button_pressed"]
[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ActionHBox/DeleteButton" to="." method="_on_delete_button_pressed"]
[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ActionHBox/BackButton" to="." method="_on_back_button_pressed"]
[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ActionHBox/LogoutButton" to="." method="_on_logout_button_pressed"]
-[connection signal="request_completed" from="LogoutRequest" to="." method="_on_logout_request_completed"]
+[connection signal="request_completed" from="LogoutRequest" to="." method="_on_logout_request_completed"]
diff --git a/game/scenes/UI/character_service.gd b/game/scenes/UI/character_service.gd
index 26c08c2..396928c 100644
--- a/game/scenes/UI/character_service.gd
+++ b/game/scenes/UI/character_service.gd
@@ -1,16 +1,16 @@
-extends Node
-
-const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters"
-
-func list_characters() -> Dictionary:
- return await _request(HTTPClient.METHOD_GET, CHARACTER_API_URL)
-
-func create_character(character_name: String) -> Dictionary:
- var payload := JSON.stringify({
- "name": character_name
- })
- return await _request(HTTPClient.METHOD_POST, CHARACTER_API_URL, payload)
-
+extends Node
+
+const CHARACTER_API_URL := "https://pchar.ranaze.com/api/Characters"
+
+func list_characters() -> Dictionary:
+ return await _request(HTTPClient.METHOD_GET, CHARACTER_API_URL)
+
+func create_character(character_name: String) -> Dictionary:
+ var payload := JSON.stringify({
+ "name": character_name
+ })
+ return await _request(HTTPClient.METHOD_POST, CHARACTER_API_URL, payload)
+
func delete_character(character_id: String) -> Dictionary:
var url := "%s/%s" % [CHARACTER_API_URL, character_id]
return await _request(HTTPClient.METHOD_DELETE, url)
@@ -28,45 +28,45 @@ func update_character_coord(character_id: String, coord: Vector2i) -> Dictionary
func heartbeat_character(character_id: String) -> Dictionary:
var url := "%s/%s/heartbeat" % [CHARACTER_API_URL, character_id]
return await _request(HTTPClient.METHOD_POST, url, "")
-
-func _request(method: int, url: String, body: String = "") -> Dictionary:
- var request := HTTPRequest.new()
- add_child(request)
-
- var headers := PackedStringArray()
- if not AuthState.access_token.is_empty():
- headers.append("Authorization: Bearer %s" % AuthState.access_token)
- if method == HTTPClient.METHOD_POST or method == HTTPClient.METHOD_PUT:
- headers.append("Content-Type: application/json")
-
- var err := request.request(url, headers, method, body)
- if err != OK:
- request.queue_free()
- return {
- "ok": false,
- "status": 0,
- "error": "Failed to send request (%s)." % err,
- "body": ""
- }
-
- var result: Array = await request.request_completed
- request.queue_free()
-
- var result_code: int = result[0]
- var response_code: int = result[1]
- var response_body: String = result[3].get_string_from_utf8()
-
- if result_code != HTTPRequest.RESULT_SUCCESS:
- return {
- "ok": false,
- "status": response_code,
- "error": "Network error (%s)." % result_code,
- "body": response_body
- }
-
- return {
- "ok": response_code >= 200 and response_code < 300,
- "status": response_code,
- "error": "",
- "body": response_body
- }
+
+func _request(method: int, url: String, body: String = "") -> Dictionary:
+ var request := HTTPRequest.new()
+ add_child(request)
+
+ var headers := PackedStringArray()
+ if not AuthState.access_token.is_empty():
+ headers.append("Authorization: Bearer %s" % AuthState.access_token)
+ if method == HTTPClient.METHOD_POST or method == HTTPClient.METHOD_PUT:
+ headers.append("Content-Type: application/json")
+
+ var err := request.request(url, headers, method, body)
+ if err != OK:
+ request.queue_free()
+ return {
+ "ok": false,
+ "status": 0,
+ "error": "Failed to send request (%s)." % err,
+ "body": ""
+ }
+
+ var result: Array = await request.request_completed
+ request.queue_free()
+
+ var result_code: int = result[0]
+ var response_code: int = result[1]
+ var response_body: String = result[3].get_string_from_utf8()
+
+ if result_code != HTTPRequest.RESULT_SUCCESS:
+ return {
+ "ok": false,
+ "status": response_code,
+ "error": "Network error (%s)." % result_code,
+ "body": response_body
+ }
+
+ return {
+ "ok": response_code >= 200 and response_code < 300,
+ "status": response_code,
+ "error": "",
+ "body": response_body
+ }
diff --git a/game/scenes/UI/login_screen.gd b/game/scenes/UI/login_screen.gd
index 4008e41..ef4da99 100644
--- a/game/scenes/UI/login_screen.gd
+++ b/game/scenes/UI/login_screen.gd
@@ -1,7 +1,7 @@
-extends Control
-
-const AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login"
-
+extends Control
+
+const AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login"
+
@onready var _username_input: LineEdit = %UsernameInput
@onready var _password_input: LineEdit = %PasswordInput
@onready var _login_request: HTTPRequest = %LoginRequest
@@ -18,38 +18,38 @@ func _on_log_in_button_pressed() -> void:
var password := _password_input.text
if username.is_empty() or password.is_empty():
_show_error("Username and password required.")
- return
-
- var payload := {
- "username": username,
- "password": password,
- }
- var headers := PackedStringArray(["Content-Type: application/json"])
- var err := _login_request.request(AUTH_LOGIN_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
- if err != OK:
- _show_error("Failed to send login request.")
-
-func _on_login_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
- var body_text := body.get_string_from_utf8()
- if result != HTTPRequest.RESULT_SUCCESS:
- _show_error("Network error. Please try again.")
- return
-
- if response_code >= 200 and response_code < 300:
- var response: Variant = JSON.parse_string(body_text)
- if typeof(response) == TYPE_DICTIONARY:
- #print("Login success for %s" % response.get("username", "unknown"))
- #print("Access Token: %s" % response.get("accessToken", ""))
- var token := String(response.get("accessToken", ""))
- var username := String(response.get("username", ""))
- AuthState.set_session(username, token)
- get_tree().change_scene_to_file("res://scenes/UI/character_screen.tscn")
- else:
- _show_error("Login failed. Check your credentials.")
-
-func _on_back_button_pressed() -> void:
- get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
-
+ return
+
+ var payload := {
+ "username": username,
+ "password": password,
+ }
+ var headers := PackedStringArray(["Content-Type: application/json"])
+ var err := _login_request.request(AUTH_LOGIN_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
+ if err != OK:
+ _show_error("Failed to send login request.")
+
+func _on_login_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
+ var body_text := body.get_string_from_utf8()
+ if result != HTTPRequest.RESULT_SUCCESS:
+ _show_error("Network error. Please try again.")
+ return
+
+ if response_code >= 200 and response_code < 300:
+ var response: Variant = JSON.parse_string(body_text)
+ if typeof(response) == TYPE_DICTIONARY:
+ #print("Login success for %s" % response.get("username", "unknown"))
+ #print("Access Token: %s" % response.get("accessToken", ""))
+ var token := String(response.get("accessToken", ""))
+ var username := String(response.get("username", ""))
+ AuthState.set_session(username, token)
+ get_tree().change_scene_to_file("res://scenes/UI/character_screen.tscn")
+ else:
+ _show_error("Login failed. Check your credentials.")
+
+func _on_back_button_pressed() -> void:
+ get_tree().change_scene_to_file("res://scenes/UI/start_screen.tscn")
+
func _show_error(message: String) -> void:
_error_label.text = message
diff --git a/game/scenes/UI/login_screen.tscn b/game/scenes/UI/login_screen.tscn
index 2b7386d..007072d 100644
--- a/game/scenes/UI/login_screen.tscn
+++ b/game/scenes/UI/login_screen.tscn
@@ -1,117 +1,117 @@
-[gd_scene load_steps=5 format=3 uid="uid://fmp1tah03kew"]
-
-[ext_resource type="Script" path="res://scenes/UI/login_screen.gd" id="1_jqkpi"]
-[ext_resource type="Texture2D" uid="uid://dhuosr0p605gj" path="res://assets/images/pp_start_bg.png" id="2_2n6di"]
-[ext_resource type="Theme" uid="uid://tn8qndst18d6" path="res://themes/title_font_theme.tres" id="3_c4k70"]
-[ext_resource type="Theme" uid="uid://wpxmub0n2dr3" path="res://themes/button_theme.tres" id="4_gx673"]
-
-[node name="LoginScreen" 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_jqkpi")
-
-[node name="TextureRect" type="TextureRect" parent="."]
-layout_mode = 1
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-texture = ExtResource("2_2n6di")
-
-[node name="MarginContainer" type="MarginContainer" parent="."]
-layout_mode = 1
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-theme_override_constants/margin_left = 80
-theme_override_constants/margin_top = 40
-theme_override_constants/margin_right = 80
-theme_override_constants/margin_bottom = 40
-
-[node name="ContentCenter" type="CenterContainer" parent="MarginContainer"]
-layout_mode = 1
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-
-[node name="ContentVBox" type="VBoxContainer" parent="MarginContainer/ContentCenter"]
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 4
-theme_override_constants/separation = 24
-
-[node name="TitleLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme = ExtResource("3_c4k70")
-text = "ACCOUNT LOGIN"
-horizontal_alignment = 1
-
-[node name="FormVBox" type="VBoxContainer" parent="MarginContainer/ContentCenter/ContentVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme_override_constants/separation = 8
-custom_minimum_size = Vector2(480, 0)
-
-[node name="UsernameLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox/FormVBox"]
-layout_mode = 2
-text = "Username"
-
-[node name="UsernameInput" type="LineEdit" parent="MarginContainer/ContentCenter/ContentVBox/FormVBox"]
-unique_name_in_owner = true
-layout_mode = 2
-placeholder_text = "enter username"
-caret_blink = true
-
-[node name="PasswordLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox/FormVBox"]
-layout_mode = 2
-text = "Password"
-
-[node name="PasswordInput" type="LineEdit" parent="MarginContainer/ContentCenter/ContentVBox/FormVBox"]
-unique_name_in_owner = true
-layout_mode = 2
-placeholder_text = "enter password"
-secret = true
-
-[node name="ButtonVBox" type="VBoxContainer" parent="MarginContainer/ContentCenter/ContentVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme_override_constants/separation = 12
-
-[node name="ErrorLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox/ButtonVBox"]
-unique_name_in_owner = true
-layout_mode = 2
-theme = ExtResource("3_c4k70")
-horizontal_alignment = 1
-theme_override_font_sizes/font_size = 26
-theme_override_colors/font_color = Color(1, 0.2, 0.2, 1)
-
-[node name="LogInButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ButtonVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme = ExtResource("4_gx673")
-text = "LOG IN"
-text_alignment = 1
-
-[node name="BackButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ButtonVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme = ExtResource("4_gx673")
-text = "BACK"
-text_alignment = 1
-
-[node name="LoginRequest" type="HTTPRequest" parent="."]
-unique_name_in_owner = true
-
-[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ButtonVBox/LogInButton" to="." method="_on_log_in_button_pressed"]
-[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ButtonVBox/BackButton" to="." method="_on_back_button_pressed"]
-[connection signal="request_completed" from="LoginRequest" to="." method="_on_login_request_completed"]
+[gd_scene load_steps=5 format=3 uid="uid://fmp1tah03kew"]
+
+[ext_resource type="Script" path="res://scenes/UI/login_screen.gd" id="1_jqkpi"]
+[ext_resource type="Texture2D" uid="uid://dhuosr0p605gj" path="res://assets/images/pp_start_bg.png" id="2_2n6di"]
+[ext_resource type="Theme" uid="uid://tn8qndst18d6" path="res://themes/title_font_theme.tres" id="3_c4k70"]
+[ext_resource type="Theme" uid="uid://wpxmub0n2dr3" path="res://themes/button_theme.tres" id="4_gx673"]
+
+[node name="LoginScreen" 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_jqkpi")
+
+[node name="TextureRect" type="TextureRect" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+texture = ExtResource("2_2n6di")
+
+[node name="MarginContainer" type="MarginContainer" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 80
+theme_override_constants/margin_top = 40
+theme_override_constants/margin_right = 80
+theme_override_constants/margin_bottom = 40
+
+[node name="ContentCenter" type="CenterContainer" parent="MarginContainer"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="ContentVBox" type="VBoxContainer" parent="MarginContainer/ContentCenter"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme_override_constants/separation = 24
+
+[node name="TitleLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme = ExtResource("3_c4k70")
+text = "ACCOUNT LOGIN"
+horizontal_alignment = 1
+
+[node name="FormVBox" type="VBoxContainer" parent="MarginContainer/ContentCenter/ContentVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme_override_constants/separation = 8
+custom_minimum_size = Vector2(480, 0)
+
+[node name="UsernameLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox/FormVBox"]
+layout_mode = 2
+text = "Username"
+
+[node name="UsernameInput" type="LineEdit" parent="MarginContainer/ContentCenter/ContentVBox/FormVBox"]
+unique_name_in_owner = true
+layout_mode = 2
+placeholder_text = "enter username"
+caret_blink = true
+
+[node name="PasswordLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox/FormVBox"]
+layout_mode = 2
+text = "Password"
+
+[node name="PasswordInput" type="LineEdit" parent="MarginContainer/ContentCenter/ContentVBox/FormVBox"]
+unique_name_in_owner = true
+layout_mode = 2
+placeholder_text = "enter password"
+secret = true
+
+[node name="ButtonVBox" type="VBoxContainer" parent="MarginContainer/ContentCenter/ContentVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme_override_constants/separation = 12
+
+[node name="ErrorLabel" type="Label" parent="MarginContainer/ContentCenter/ContentVBox/ButtonVBox"]
+unique_name_in_owner = true
+layout_mode = 2
+theme = ExtResource("3_c4k70")
+horizontal_alignment = 1
+theme_override_font_sizes/font_size = 26
+theme_override_colors/font_color = Color(1, 0.2, 0.2, 1)
+
+[node name="LogInButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ButtonVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme = ExtResource("4_gx673")
+text = "LOG IN"
+text_alignment = 1
+
+[node name="BackButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ButtonVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme = ExtResource("4_gx673")
+text = "BACK"
+text_alignment = 1
+
+[node name="LoginRequest" type="HTTPRequest" parent="."]
+unique_name_in_owner = true
+
+[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ButtonVBox/LogInButton" to="." method="_on_log_in_button_pressed"]
+[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/ButtonVBox/BackButton" to="." method="_on_back_button_pressed"]
+[connection signal="request_completed" from="LoginRequest" to="." method="_on_login_request_completed"]
diff --git a/game/scenes/UI/menu_music.gd b/game/scenes/UI/menu_music.gd
index 0ecd9bb..c39db1f 100644
--- a/game/scenes/UI/menu_music.gd
+++ b/game/scenes/UI/menu_music.gd
@@ -1,107 +1,107 @@
-extends AudioStreamPlayer
-
-const MENU_SCENES: Dictionary = {
- "res://scenes/UI/start_screen.tscn": true,
- "res://scenes/UI/Settings.tscn": true,
- "res://scenes/UI/login_screen.tscn": true,
-}
-
-const CONFIG_PATH := "user://settings.cfg"
-const CONFIG_SECTION := "audio"
-const CONFIG_KEY_MUSIC_VOLUME := "menu_music_volume"
-const CONFIG_KEY_MUSIC_MUTED := "menu_music_muted"
-const DEFAULT_VOLUME := 0.7
-
-var _last_scene: Node = null
-var _user_volume_linear: float = DEFAULT_VOLUME
-var _is_muted: bool = false
-
-func _ready() -> void:
- set_process(true)
- _load_settings()
- _apply_volume()
- _update_playback(get_tree().current_scene)
-
-func _process(_delta: float) -> void:
- var current := get_tree().current_scene
- if current != _last_scene:
- _update_playback(current)
- elif _should_play_scene(current) and not playing and not _is_muted and _user_volume_linear > 0.0:
- play()
-
-func _update_playback(scene: Node) -> void:
- if scene == null:
- _last_scene = null
- return
- _last_scene = scene
- if _should_play_scene(scene):
- if not playing:
- play()
- elif playing:
- stop()
-
-func _should_play_scene(scene: Node) -> bool:
- if scene == null:
- return false
- var scene_path: String = scene.get_scene_file_path()
- if scene_path.is_empty():
- return false
- return MENU_SCENES.has(scene_path)
-
-func set_user_volume(value: float) -> void:
- var clamped_value: float = clamp(value, 0.0, 1.0)
- if is_equal_approx(_user_volume_linear, clamped_value):
- return
- _user_volume_linear = clamped_value
- _apply_volume()
- _save_settings()
-
-func get_user_volume() -> float:
- return _user_volume_linear
-
-func set_user_muted(muted: bool) -> void:
- var new_muted: bool = muted
- if _is_muted == new_muted:
- return
- _is_muted = new_muted
- _apply_volume()
- _save_settings()
-
-func is_user_muted() -> bool:
- return _is_muted
-
-func _apply_volume() -> void:
- if _is_muted or _user_volume_linear <= 0.0:
- volume_db = -80.0
- else:
- volume_db = linear_to_db(_user_volume_linear)
-
-func _load_settings() -> void:
- var config: ConfigFile = ConfigFile.new()
- var err: int = config.load(CONFIG_PATH)
- if err == OK:
- var stored_volume: float = float(config.get_value(CONFIG_SECTION, CONFIG_KEY_MUSIC_VOLUME, DEFAULT_VOLUME))
- _user_volume_linear = clamp(stored_volume, 0.0, 1.0)
- var stored_muted: bool = bool(config.get_value(CONFIG_SECTION, CONFIG_KEY_MUSIC_MUTED, false))
- _is_muted = stored_muted
- elif err == ERR_DOES_NOT_EXIST:
- _user_volume_linear = DEFAULT_VOLUME
- _is_muted = false
- else:
- push_warning("Failed to load settings.cfg: %s" % err)
- _user_volume_linear = DEFAULT_VOLUME
- _is_muted = false
-
-func _save_settings() -> void:
- var config: ConfigFile = ConfigFile.new()
- var err: int = config.load(CONFIG_PATH)
- if err != OK and err != ERR_DOES_NOT_EXIST:
- push_warning("Failed to load settings.cfg before saving: %s" % err)
- config = ConfigFile.new()
- elif err != OK:
- config = ConfigFile.new()
- config.set_value(CONFIG_SECTION, CONFIG_KEY_MUSIC_VOLUME, _user_volume_linear)
- config.set_value(CONFIG_SECTION, CONFIG_KEY_MUSIC_MUTED, _is_muted)
- var save_err: int = config.save(CONFIG_PATH)
- if save_err != OK:
- push_warning("Failed to save settings.cfg: %s" % save_err)
+extends AudioStreamPlayer
+
+const MENU_SCENES: Dictionary = {
+ "res://scenes/UI/start_screen.tscn": true,
+ "res://scenes/UI/Settings.tscn": true,
+ "res://scenes/UI/login_screen.tscn": true,
+}
+
+const CONFIG_PATH := "user://settings.cfg"
+const CONFIG_SECTION := "audio"
+const CONFIG_KEY_MUSIC_VOLUME := "menu_music_volume"
+const CONFIG_KEY_MUSIC_MUTED := "menu_music_muted"
+const DEFAULT_VOLUME := 0.7
+
+var _last_scene: Node = null
+var _user_volume_linear: float = DEFAULT_VOLUME
+var _is_muted: bool = false
+
+func _ready() -> void:
+ set_process(true)
+ _load_settings()
+ _apply_volume()
+ _update_playback(get_tree().current_scene)
+
+func _process(_delta: float) -> void:
+ var current := get_tree().current_scene
+ if current != _last_scene:
+ _update_playback(current)
+ elif _should_play_scene(current) and not playing and not _is_muted and _user_volume_linear > 0.0:
+ play()
+
+func _update_playback(scene: Node) -> void:
+ if scene == null:
+ _last_scene = null
+ return
+ _last_scene = scene
+ if _should_play_scene(scene):
+ if not playing:
+ play()
+ elif playing:
+ stop()
+
+func _should_play_scene(scene: Node) -> bool:
+ if scene == null:
+ return false
+ var scene_path: String = scene.get_scene_file_path()
+ if scene_path.is_empty():
+ return false
+ return MENU_SCENES.has(scene_path)
+
+func set_user_volume(value: float) -> void:
+ var clamped_value: float = clamp(value, 0.0, 1.0)
+ if is_equal_approx(_user_volume_linear, clamped_value):
+ return
+ _user_volume_linear = clamped_value
+ _apply_volume()
+ _save_settings()
+
+func get_user_volume() -> float:
+ return _user_volume_linear
+
+func set_user_muted(muted: bool) -> void:
+ var new_muted: bool = muted
+ if _is_muted == new_muted:
+ return
+ _is_muted = new_muted
+ _apply_volume()
+ _save_settings()
+
+func is_user_muted() -> bool:
+ return _is_muted
+
+func _apply_volume() -> void:
+ if _is_muted or _user_volume_linear <= 0.0:
+ volume_db = -80.0
+ else:
+ volume_db = linear_to_db(_user_volume_linear)
+
+func _load_settings() -> void:
+ var config: ConfigFile = ConfigFile.new()
+ var err: int = config.load(CONFIG_PATH)
+ if err == OK:
+ var stored_volume: float = float(config.get_value(CONFIG_SECTION, CONFIG_KEY_MUSIC_VOLUME, DEFAULT_VOLUME))
+ _user_volume_linear = clamp(stored_volume, 0.0, 1.0)
+ var stored_muted: bool = bool(config.get_value(CONFIG_SECTION, CONFIG_KEY_MUSIC_MUTED, false))
+ _is_muted = stored_muted
+ elif err == ERR_DOES_NOT_EXIST:
+ _user_volume_linear = DEFAULT_VOLUME
+ _is_muted = false
+ else:
+ push_warning("Failed to load settings.cfg: %s" % err)
+ _user_volume_linear = DEFAULT_VOLUME
+ _is_muted = false
+
+func _save_settings() -> void:
+ var config: ConfigFile = ConfigFile.new()
+ var err: int = config.load(CONFIG_PATH)
+ if err != OK and err != ERR_DOES_NOT_EXIST:
+ push_warning("Failed to load settings.cfg before saving: %s" % err)
+ config = ConfigFile.new()
+ elif err != OK:
+ config = ConfigFile.new()
+ config.set_value(CONFIG_SECTION, CONFIG_KEY_MUSIC_VOLUME, _user_volume_linear)
+ config.set_value(CONFIG_SECTION, CONFIG_KEY_MUSIC_MUTED, _is_muted)
+ var save_err: int = config.save(CONFIG_PATH)
+ if save_err != OK:
+ push_warning("Failed to save settings.cfg: %s" % save_err)
diff --git a/game/scenes/UI/menu_music.tscn b/game/scenes/UI/menu_music.tscn
index cfaa936..afdf4da 100644
--- a/game/scenes/UI/menu_music.tscn
+++ b/game/scenes/UI/menu_music.tscn
@@ -1,13 +1,13 @@
-[gd_scene load_steps=3 format=3 uid="uid://dfmq1n507y7d3"]
-
-[ext_resource type="AudioStream" uid="uid://txgki0ijeuud" path="res://assets/audio/silly-test.ogg" id="1_ek0t3"]
-
-[ext_resource type="Script" path="res://scenes/UI/menu_music.gd" id="2_21d4q"]
-
-[node name="MenuMusic" type="AudioStreamPlayer"]
-bus = &"Music"
-stream = ExtResource("1_ek0t3")
-autoplay = true
-volume_db = -3.0
-priority = 10.0
-script = ExtResource("2_21d4q")
+[gd_scene load_steps=3 format=3 uid="uid://dfmq1n507y7d3"]
+
+[ext_resource type="AudioStream" uid="uid://txgki0ijeuud" path="res://assets/audio/silly-test.ogg" id="1_ek0t3"]
+
+[ext_resource type="Script" path="res://scenes/UI/menu_music.gd" id="2_21d4q"]
+
+[node name="MenuMusic" type="AudioStreamPlayer"]
+bus = &"Music"
+stream = ExtResource("1_ek0t3")
+autoplay = true
+volume_db = -3.0
+priority = 10.0
+script = ExtResource("2_21d4q")
diff --git a/game/scenes/UI/menu_sfx.gd b/game/scenes/UI/menu_sfx.gd
index 21db5d4..ab57e14 100644
--- a/game/scenes/UI/menu_sfx.gd
+++ b/game/scenes/UI/menu_sfx.gd
@@ -1,76 +1,76 @@
-extends AudioStreamPlayer
-
-const CONFIG_PATH := "user://settings.cfg"
-const CONFIG_SECTION := "audio"
-const CONFIG_KEY_VOLUME := "menu_sfx_volume"
-const CONFIG_KEY_MUTED := "menu_sfx_muted"
-const DEFAULT_VOLUME := 0.7
-
-var _user_volume_linear: float = DEFAULT_VOLUME
-var _is_muted: bool = false
-
-func _ready() -> void:
- _load_settings()
- _apply_volume()
-
-func play_hover() -> void:
- if stream == null or _is_muted or _user_volume_linear <= 0.0:
- return
- play(0.0)
-
-func set_user_volume(value: float) -> void:
- var clamped_value: float = clamp(value, 0.0, 1.0)
- if is_equal_approx(_user_volume_linear, clamped_value):
- return
- _user_volume_linear = clamped_value
- _apply_volume()
- _save_settings()
-
-func get_user_volume() -> float:
- return _user_volume_linear
-
-func set_user_muted(muted: bool) -> void:
- if _is_muted == muted:
- return
- _is_muted = muted
- _apply_volume()
- _save_settings()
- if _is_muted:
- stop()
-
-func is_user_muted() -> bool:
- return _is_muted
-
-func _apply_volume() -> void:
- if _is_muted or _user_volume_linear <= 0.0:
- volume_db = -80.0
- else:
- volume_db = linear_to_db(_user_volume_linear)
-
-func _load_settings() -> void:
- var config: ConfigFile = ConfigFile.new()
- var err: int = config.load(CONFIG_PATH)
- if err == OK:
- _user_volume_linear = clamp(float(config.get_value(CONFIG_SECTION, CONFIG_KEY_VOLUME, DEFAULT_VOLUME)), 0.0, 1.0)
- _is_muted = bool(config.get_value(CONFIG_SECTION, CONFIG_KEY_MUTED, false))
- elif err == ERR_DOES_NOT_EXIST:
- _user_volume_linear = DEFAULT_VOLUME
- _is_muted = false
- else:
- push_warning("Failed to load settings.cfg: %s" % err)
- _user_volume_linear = DEFAULT_VOLUME
- _is_muted = false
-
-func _save_settings() -> void:
- var config: ConfigFile = ConfigFile.new()
- var err: int = config.load(CONFIG_PATH)
- if err != OK and err != ERR_DOES_NOT_EXIST:
- push_warning("Failed to load settings.cfg before saving: %s" % err)
- config = ConfigFile.new()
- elif err != OK:
- config = ConfigFile.new()
- config.set_value(CONFIG_SECTION, CONFIG_KEY_VOLUME, _user_volume_linear)
- config.set_value(CONFIG_SECTION, CONFIG_KEY_MUTED, _is_muted)
- var save_err: int = config.save(CONFIG_PATH)
- if save_err != OK:
- push_warning("Failed to save settings.cfg: %s" % save_err)
+extends AudioStreamPlayer
+
+const CONFIG_PATH := "user://settings.cfg"
+const CONFIG_SECTION := "audio"
+const CONFIG_KEY_VOLUME := "menu_sfx_volume"
+const CONFIG_KEY_MUTED := "menu_sfx_muted"
+const DEFAULT_VOLUME := 0.7
+
+var _user_volume_linear: float = DEFAULT_VOLUME
+var _is_muted: bool = false
+
+func _ready() -> void:
+ _load_settings()
+ _apply_volume()
+
+func play_hover() -> void:
+ if stream == null or _is_muted or _user_volume_linear <= 0.0:
+ return
+ play(0.0)
+
+func set_user_volume(value: float) -> void:
+ var clamped_value: float = clamp(value, 0.0, 1.0)
+ if is_equal_approx(_user_volume_linear, clamped_value):
+ return
+ _user_volume_linear = clamped_value
+ _apply_volume()
+ _save_settings()
+
+func get_user_volume() -> float:
+ return _user_volume_linear
+
+func set_user_muted(muted: bool) -> void:
+ if _is_muted == muted:
+ return
+ _is_muted = muted
+ _apply_volume()
+ _save_settings()
+ if _is_muted:
+ stop()
+
+func is_user_muted() -> bool:
+ return _is_muted
+
+func _apply_volume() -> void:
+ if _is_muted or _user_volume_linear <= 0.0:
+ volume_db = -80.0
+ else:
+ volume_db = linear_to_db(_user_volume_linear)
+
+func _load_settings() -> void:
+ var config: ConfigFile = ConfigFile.new()
+ var err: int = config.load(CONFIG_PATH)
+ if err == OK:
+ _user_volume_linear = clamp(float(config.get_value(CONFIG_SECTION, CONFIG_KEY_VOLUME, DEFAULT_VOLUME)), 0.0, 1.0)
+ _is_muted = bool(config.get_value(CONFIG_SECTION, CONFIG_KEY_MUTED, false))
+ elif err == ERR_DOES_NOT_EXIST:
+ _user_volume_linear = DEFAULT_VOLUME
+ _is_muted = false
+ else:
+ push_warning("Failed to load settings.cfg: %s" % err)
+ _user_volume_linear = DEFAULT_VOLUME
+ _is_muted = false
+
+func _save_settings() -> void:
+ var config: ConfigFile = ConfigFile.new()
+ var err: int = config.load(CONFIG_PATH)
+ if err != OK and err != ERR_DOES_NOT_EXIST:
+ push_warning("Failed to load settings.cfg before saving: %s" % err)
+ config = ConfigFile.new()
+ elif err != OK:
+ config = ConfigFile.new()
+ config.set_value(CONFIG_SECTION, CONFIG_KEY_VOLUME, _user_volume_linear)
+ config.set_value(CONFIG_SECTION, CONFIG_KEY_MUTED, _is_muted)
+ var save_err: int = config.save(CONFIG_PATH)
+ if save_err != OK:
+ push_warning("Failed to save settings.cfg: %s" % save_err)
diff --git a/game/scenes/UI/menu_sfx.tscn b/game/scenes/UI/menu_sfx.tscn
index b0a9521..2c07257 100644
--- a/game/scenes/UI/menu_sfx.tscn
+++ b/game/scenes/UI/menu_sfx.tscn
@@ -1,13 +1,13 @@
-[gd_scene load_steps=3 format=3 uid="uid://dt785sv7ie7uj"]
-
-[ext_resource type="AudioStream" uid="uid://64dplcgx2icb" path="res://assets/audio/silly-menu-hover-test.ogg" id="1_a5j5k"]
-[ext_resource type="Script" path="res://scenes/UI/menu_sfx.gd" id="1_ijvfa"]
-
-[node name="MenuSfx" type="AudioStreamPlayer"]
-bus = &"SFX"
-stream = ExtResource("1_a5j5k")
-volume_db = -6.0
-autoplay = false
-priority = 0.5
-max_polyphony = 4
-script = ExtResource("1_ijvfa")
+[gd_scene load_steps=3 format=3 uid="uid://dt785sv7ie7uj"]
+
+[ext_resource type="AudioStream" uid="uid://64dplcgx2icb" path="res://assets/audio/silly-menu-hover-test.ogg" id="1_a5j5k"]
+[ext_resource type="Script" path="res://scenes/UI/menu_sfx.gd" id="1_ijvfa"]
+
+[node name="MenuSfx" type="AudioStreamPlayer"]
+bus = &"SFX"
+stream = ExtResource("1_a5j5k")
+volume_db = -6.0
+autoplay = false
+priority = 0.5
+max_polyphony = 4
+script = ExtResource("1_ijvfa")
diff --git a/game/scenes/player.gd b/game/scenes/player.gd
index 99923a4..d9dc2bb 100644
--- a/game/scenes/player.gd
+++ b/game/scenes/player.gd
@@ -271,6 +271,18 @@ func exit_vehicle(exit_point: Node3D, vehicle_camera: Camera3D) -> void:
vehicle_exited.emit(null)
+func teleport_to_spawn(spawn_transform: Transform3D) -> void:
+ global_transform = spawn_transform
+ linear_velocity = Vector3.ZERO
+ angular_velocity = Vector3.ZERO
+ sleeping = false
+ _camera_yaw = global_transform.basis.get_euler().y
+ if cam:
+ var target_basis := Basis(Vector3.UP, _camera_yaw)
+ cam.global_position = global_position + (target_basis * _camera_offset_local)
+ cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
+
+
func is_in_vehicle() -> bool:
return _in_vehicle
diff --git a/game/themes/title_font_theme.tres b/game/themes/title_font_theme.tres
index 36fdd73..be92bdc 100644
--- a/game/themes/title_font_theme.tres
+++ b/game/themes/title_font_theme.tres
@@ -1,7 +1,7 @@
-[gd_resource type="Theme" load_steps=2 format=3 uid="uid://tn8qndst18d6"]
-
-[ext_resource type="FontFile" uid="uid://m5ceou0rk6j6" path="res://assets/fonts/PlayfairDisplay-VariableFont_wght.ttf" id="1_vd7w8"]
-
-[resource]
-Label/font_sizes/font_size = 60
-Label/fonts/font = ExtResource("1_vd7w8")
+[gd_resource type="Theme" load_steps=2 format=3 uid="uid://tn8qndst18d6"]
+
+[ext_resource type="FontFile" uid="uid://m5ceou0rk6j6" path="res://assets/fonts/PlayfairDisplay-VariableFont_wght.ttf" id="1_vd7w8"]
+
+[resource]
+Label/font_sizes/font_size = 60
+Label/fonts/font = ExtResource("1_vd7w8")
diff --git a/microservices/AuthApi/AuthApi.csproj b/microservices/AuthApi/AuthApi.csproj
index 0aae074..66c00d8 100644
--- a/microservices/AuthApi/AuthApi.csproj
+++ b/microservices/AuthApi/AuthApi.csproj
@@ -1,17 +1,17 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/microservices/AuthApi/Controllers/AuthController.cs b/microservices/AuthApi/Controllers/AuthController.cs
index 192b9a6..d91fd07 100644
--- a/microservices/AuthApi/Controllers/AuthController.cs
+++ b/microservices/AuthApi/Controllers/AuthController.cs
@@ -1,113 +1,113 @@
-using AuthApi.Models;
-using AuthApi.Services;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.IdentityModel.Tokens;
-using System.IdentityModel.Tokens.Jwt;
-using System.Security.Claims;
-using System.Text;
-
-namespace AuthApi.Controllers;
-
-[ApiController]
-[Route("api/[controller]")]
-public class AuthController : ControllerBase
-{
- private readonly UserService _users;
- private readonly IConfiguration _cfg;
- private readonly BlacklistService _blacklist;
-
- public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist)
- {
- _users = users; _cfg = cfg; _blacklist = blacklist;
- }
-
- [HttpPost("register")]
- public async Task Register([FromBody] RegisterRequest req)
- {
- if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
- return BadRequest("Username and password required");
-
- if (await _users.GetByUsernameAsync(req.Username) != null)
- return BadRequest("User already exists");
-
- var hash = BCrypt.Net.BCrypt.HashPassword(req.Password);
- var user = new User { Username = req.Username, PasswordHash = hash, Role = "USER", Email = req.Email };
- await _users.CreateAsync(user);
- return Ok("User created");
- }
-
- [HttpPost("login")]
- public async Task Login([FromBody] LoginRequest req)
- {
- var user = await _users.GetByUsernameAsync(req.Username);
- if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
- return Unauthorized();
-
- var (accessToken, jti, expUtc) = GenerateJwtToken(user);
- user.RefreshToken = Guid.NewGuid().ToString("N");
- user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
- await _users.UpdateAsync(user);
-
- return Ok(new { accessToken, refreshToken = user.RefreshToken, user.Username, user.Role, jti, exp = expUtc });
- }
-
- [HttpPost("refresh")]
- public async Task Refresh([FromBody] RefreshRequest req)
- {
- var user = await _users.GetByUsernameAsync(req.Username);
- if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow)
- return Unauthorized("Invalid or expired refresh token");
-
- var (accessToken, _, expUtc) = GenerateJwtToken(user);
- return Ok(new { accessToken, exp = expUtc });
- }
-
- [HttpPost("logout")]
- [Authorize(Roles = "USER,SUPER")]
- public async Task Logout()
- {
- var token = HttpContext.Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", "");
- if (string.IsNullOrWhiteSpace(token)) return BadRequest("Token missing");
- var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
- await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo);
- return Ok("Logged out.");
- }
-
- [HttpPost("role")]
- [Authorize(Roles = "SUPER")]
- public async Task ChangeUserRole([FromBody] ChangeRoleRequest req)
- {
- if (req.NewRole is not ("USER" or "SUPER")) return BadRequest("Role must be 'USER' or 'SUPER'");
- var user = await _users.GetByUsernameAsync(req.Username);
- if (user is null) return NotFound("User not found");
- user.Role = req.NewRole;
- await _users.UpdateAsync(user);
- return Ok($"{req.Username}'s role updated to {req.NewRole}");
- }
-
- [HttpGet("users")]
- [Authorize(Roles = "SUPER")]
- public async Task GetAllUsers() => Ok(await _users.GetAllAsync());
-
- private (string token, string jti, DateTime expUtc) GenerateJwtToken(User user)
- {
- var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]!);
- var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
- var audience = _cfg["Jwt:Audience"] ?? issuer;
-
- var creds = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);
- var jti = Guid.NewGuid().ToString("N");
- var claims = new[]
- {
- new Claim(ClaimTypes.Name, user.Username),
- new Claim(ClaimTypes.NameIdentifier, user.Id),
- new Claim(ClaimTypes.Role, user.Role),
- new Claim(JwtRegisteredClaimNames.Jti, jti)
- };
-
- var exp = DateTime.UtcNow.AddMinutes(15);
- var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds);
- return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp);
- }
-}
+using AuthApi.Models;
+using AuthApi.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.IdentityModel.Tokens;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+
+namespace AuthApi.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class AuthController : ControllerBase
+{
+ private readonly UserService _users;
+ private readonly IConfiguration _cfg;
+ private readonly BlacklistService _blacklist;
+
+ public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist)
+ {
+ _users = users; _cfg = cfg; _blacklist = blacklist;
+ }
+
+ [HttpPost("register")]
+ public async Task Register([FromBody] RegisterRequest req)
+ {
+ if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
+ return BadRequest("Username and password required");
+
+ if (await _users.GetByUsernameAsync(req.Username) != null)
+ return BadRequest("User already exists");
+
+ var hash = BCrypt.Net.BCrypt.HashPassword(req.Password);
+ var user = new User { Username = req.Username, PasswordHash = hash, Role = "USER", Email = req.Email };
+ await _users.CreateAsync(user);
+ return Ok("User created");
+ }
+
+ [HttpPost("login")]
+ public async Task Login([FromBody] LoginRequest req)
+ {
+ var user = await _users.GetByUsernameAsync(req.Username);
+ if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
+ return Unauthorized();
+
+ var (accessToken, jti, expUtc) = GenerateJwtToken(user);
+ user.RefreshToken = Guid.NewGuid().ToString("N");
+ user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
+ await _users.UpdateAsync(user);
+
+ return Ok(new { accessToken, refreshToken = user.RefreshToken, user.Username, user.Role, jti, exp = expUtc });
+ }
+
+ [HttpPost("refresh")]
+ public async Task Refresh([FromBody] RefreshRequest req)
+ {
+ var user = await _users.GetByUsernameAsync(req.Username);
+ if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow)
+ return Unauthorized("Invalid or expired refresh token");
+
+ var (accessToken, _, expUtc) = GenerateJwtToken(user);
+ return Ok(new { accessToken, exp = expUtc });
+ }
+
+ [HttpPost("logout")]
+ [Authorize(Roles = "USER,SUPER")]
+ public async Task Logout()
+ {
+ var token = HttpContext.Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", "");
+ if (string.IsNullOrWhiteSpace(token)) return BadRequest("Token missing");
+ var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
+ await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo);
+ return Ok("Logged out.");
+ }
+
+ [HttpPost("role")]
+ [Authorize(Roles = "SUPER")]
+ public async Task ChangeUserRole([FromBody] ChangeRoleRequest req)
+ {
+ if (req.NewRole is not ("USER" or "SUPER")) return BadRequest("Role must be 'USER' or 'SUPER'");
+ var user = await _users.GetByUsernameAsync(req.Username);
+ if (user is null) return NotFound("User not found");
+ user.Role = req.NewRole;
+ await _users.UpdateAsync(user);
+ return Ok($"{req.Username}'s role updated to {req.NewRole}");
+ }
+
+ [HttpGet("users")]
+ [Authorize(Roles = "SUPER")]
+ public async Task GetAllUsers() => Ok(await _users.GetAllAsync());
+
+ private (string token, string jti, DateTime expUtc) GenerateJwtToken(User user)
+ {
+ var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]!);
+ var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
+ var audience = _cfg["Jwt:Audience"] ?? issuer;
+
+ var creds = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256);
+ var jti = Guid.NewGuid().ToString("N");
+ var claims = new[]
+ {
+ new Claim(ClaimTypes.Name, user.Username),
+ new Claim(ClaimTypes.NameIdentifier, user.Id),
+ new Claim(ClaimTypes.Role, user.Role),
+ new Claim(JwtRegisteredClaimNames.Jti, jti)
+ };
+
+ var exp = DateTime.UtcNow.AddMinutes(15);
+ var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds);
+ return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp);
+ }
+}
diff --git a/microservices/AuthApi/Models/Dto.cs b/microservices/AuthApi/Models/Dto.cs
index 1b43cdc..cd6897c 100644
--- a/microservices/AuthApi/Models/Dto.cs
+++ b/microservices/AuthApi/Models/Dto.cs
@@ -1,6 +1,6 @@
-namespace AuthApi.Models;
-
-public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } }
-public class LoginRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; }
-public class ChangeRoleRequest { public string Username { get; set; } = ""; public string NewRole { get; set; } = ""; }
-public class RefreshRequest { public string Username { get; set; } = ""; public string RefreshToken { get; set; } = ""; }
+namespace AuthApi.Models;
+
+public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } }
+public class LoginRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; }
+public class ChangeRoleRequest { public string Username { get; set; } = ""; public string NewRole { get; set; } = ""; }
+public class RefreshRequest { public string Username { get; set; } = ""; public string RefreshToken { get; set; } = ""; }
diff --git a/microservices/AuthApi/Models/User.cs b/microservices/AuthApi/Models/User.cs
index 1c4ed9a..3ff9d76 100644
--- a/microservices/AuthApi/Models/User.cs
+++ b/microservices/AuthApi/Models/User.cs
@@ -1,16 +1,16 @@
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace AuthApi.Models;
-
-public class User
-{
- [BsonId] [BsonRepresentation(BsonType.ObjectId)]
- public string Id { get; set; } = default!;
- public string Username { get; set; } = default!;
- public string PasswordHash { get; set; } = default!;
- public string Role { get; set; } = "USER";
- public string? Email { get; set; }
- public string? RefreshToken { get; set; }
- public DateTime? RefreshTokenExpiry { get; set; }
-}
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace AuthApi.Models;
+
+public class User
+{
+ [BsonId] [BsonRepresentation(BsonType.ObjectId)]
+ public string Id { get; set; } = default!;
+ public string Username { get; set; } = default!;
+ public string PasswordHash { get; set; } = default!;
+ public string Role { get; set; } = "USER";
+ public string? Email { get; set; }
+ public string? RefreshToken { get; set; }
+ public DateTime? RefreshTokenExpiry { get; set; }
+}
diff --git a/microservices/AuthApi/Program.cs b/microservices/AuthApi/Program.cs
index 8a5729c..617173b 100644
--- a/microservices/AuthApi/Program.cs
+++ b/microservices/AuthApi/Program.cs
@@ -1,86 +1,86 @@
-using AuthApi.Services;
-using Microsoft.AspNetCore.Authentication.JwtBearer;
-using Microsoft.IdentityModel.Tokens;
-using Microsoft.OpenApi.Models;
-using System.Security.Claims;
-using System.Text;
-
-var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddControllers();
-
-// DI
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-
-// Swagger + JWT auth in Swagger
-builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen(c =>
-{
- c.SwaggerDoc("v1", new OpenApiInfo { Title = "Auth API", Version = "v1" });
- c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
- {
- Type = SecuritySchemeType.Http,
- Scheme = "bearer",
- BearerFormat = "JWT",
- Description = "Paste your access token here (no 'Bearer ' prefix needed)."
- });
- c.AddSecurityRequirement(new OpenApiSecurityRequirement
- {
- {
- new OpenApiSecurityScheme
- {
- Reference = new OpenApiReference
- { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
- },
- Array.Empty()
- }
- });
-});
-
-// AuthN/JWT
-var cfg = builder.Configuration;
-var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing");
-var issuer = cfg["Jwt:Issuer"] ?? "GameAuthApi";
-var aud = cfg["Jwt:Audience"] ?? issuer;
-
-builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddJwtBearer(o =>
- {
- o.TokenValidationParameters = new TokenValidationParameters
- {
- ValidateIssuer = true, ValidIssuer = issuer,
- ValidateAudience = true, ValidAudience = aud,
- ValidateIssuerSigningKey = true,
- IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
- ValidateLifetime = true,
- ClockSkew = TimeSpan.FromSeconds(30)
- };
- o.Events = new JwtBearerEvents
- {
- OnTokenValidated = async ctx =>
- {
- var jti = ctx.Principal?.FindFirstValue(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti);
- if (!string.IsNullOrEmpty(jti))
- {
- var bl = ctx.HttpContext.RequestServices.GetRequiredService();
- if (await bl.IsBlacklistedAsync(jti)) ctx.Fail("Token revoked");
- }
- }
- };
- });
-
-builder.Services.AddAuthorization();
-
-var app = builder.Build();
-
-app.MapGet("/healthz", () => Results.Ok("ok"));
-app.UseSwagger();
-app.UseSwaggerUI(o =>
-{
- o.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1");
- o.RoutePrefix = "swagger";
-});
-app.UseAuthentication();
-app.UseAuthorization();
-app.MapControllers();
-app.Run();
+using AuthApi.Services;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.OpenApi.Models;
+using System.Security.Claims;
+using System.Text;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddControllers();
+
+// DI
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// Swagger + JWT auth in Swagger
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo { Title = "Auth API", Version = "v1" });
+ c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
+ {
+ Type = SecuritySchemeType.Http,
+ Scheme = "bearer",
+ BearerFormat = "JWT",
+ Description = "Paste your access token here (no 'Bearer ' prefix needed)."
+ });
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
+ },
+ Array.Empty()
+ }
+ });
+});
+
+// AuthN/JWT
+var cfg = builder.Configuration;
+var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing");
+var issuer = cfg["Jwt:Issuer"] ?? "GameAuthApi";
+var aud = cfg["Jwt:Audience"] ?? issuer;
+
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(o =>
+ {
+ o.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true, ValidIssuer = issuer,
+ ValidateAudience = true, ValidAudience = aud,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromSeconds(30)
+ };
+ o.Events = new JwtBearerEvents
+ {
+ OnTokenValidated = async ctx =>
+ {
+ var jti = ctx.Principal?.FindFirstValue(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti);
+ if (!string.IsNullOrEmpty(jti))
+ {
+ var bl = ctx.HttpContext.RequestServices.GetRequiredService();
+ if (await bl.IsBlacklistedAsync(jti)) ctx.Fail("Token revoked");
+ }
+ }
+ };
+ });
+
+builder.Services.AddAuthorization();
+
+var app = builder.Build();
+
+app.MapGet("/healthz", () => Results.Ok("ok"));
+app.UseSwagger();
+app.UseSwaggerUI(o =>
+{
+ o.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1");
+ o.RoutePrefix = "swagger";
+});
+app.UseAuthentication();
+app.UseAuthorization();
+app.MapControllers();
+app.Run();
diff --git a/microservices/AuthApi/Properties/launchSettings.json b/microservices/AuthApi/Properties/launchSettings.json
index 375332c..dcb9958 100644
--- a/microservices/AuthApi/Properties/launchSettings.json
+++ b/microservices/AuthApi/Properties/launchSettings.json
@@ -1,23 +1,23 @@
-{
- "$schema": "https://json.schemastore.org/launchsettings.json",
- "profiles": {
- "http": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": false,
- "applicationUrl": "http://localhost:5279",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "https": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": false,
- "applicationUrl": "https://localhost:7295;http://localhost:5279",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- }
- }
-}
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5279",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7295;http://localhost:5279",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/microservices/AuthApi/Services/BlacklistService.cs b/microservices/AuthApi/Services/BlacklistService.cs
index 58888a7..958a558 100644
--- a/microservices/AuthApi/Services/BlacklistService.cs
+++ b/microservices/AuthApi/Services/BlacklistService.cs
@@ -1,36 +1,36 @@
-using MongoDB.Bson.Serialization.Attributes;
-using MongoDB.Driver;
-
-namespace AuthApi.Services;
-
-public class BlacklistedToken
-{
- [BsonId] public string Jti { get; set; } = default!;
- public DateTime ExpiresAt { get; set; }
-}
-
-public class BlacklistService
-{
- private readonly IMongoCollection _col;
-
- public BlacklistService(IConfiguration cfg)
- {
- var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
- var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
- var client = new MongoClient(cs);
- var db = client.GetDatabase(dbName);
- _col = db.GetCollection("BlacklistedTokens");
-
- // TTL index so revocations expire automatically
- var keys = Builders.IndexKeys.Ascending(x => x.ExpiresAt);
- _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
- }
-
- public Task AddToBlacklistAsync(string jti, DateTime expiresAt) =>
- _col.ReplaceOneAsync(x => x.Jti == jti,
- new BlacklistedToken { Jti = jti, ExpiresAt = expiresAt },
- new ReplaceOptions { IsUpsert = true });
-
- public Task IsBlacklistedAsync(string jti) =>
- _col.Find(x => x.Jti == jti).AnyAsync();
-}
+using MongoDB.Bson.Serialization.Attributes;
+using MongoDB.Driver;
+
+namespace AuthApi.Services;
+
+public class BlacklistedToken
+{
+ [BsonId] public string Jti { get; set; } = default!;
+ public DateTime ExpiresAt { get; set; }
+}
+
+public class BlacklistService
+{
+ private readonly IMongoCollection _col;
+
+ public BlacklistService(IConfiguration cfg)
+ {
+ var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
+ var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
+ var client = new MongoClient(cs);
+ var db = client.GetDatabase(dbName);
+ _col = db.GetCollection("BlacklistedTokens");
+
+ // TTL index so revocations expire automatically
+ var keys = Builders.IndexKeys.Ascending(x => x.ExpiresAt);
+ _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
+ }
+
+ public Task AddToBlacklistAsync(string jti, DateTime expiresAt) =>
+ _col.ReplaceOneAsync(x => x.Jti == jti,
+ new BlacklistedToken { Jti = jti, ExpiresAt = expiresAt },
+ new ReplaceOptions { IsUpsert = true });
+
+ public Task IsBlacklistedAsync(string jti) =>
+ _col.Find(x => x.Jti == jti).AnyAsync();
+}
diff --git a/microservices/AuthApi/Services/UserService.cs b/microservices/AuthApi/Services/UserService.cs
index eae3e4e..9bda631 100644
--- a/microservices/AuthApi/Services/UserService.cs
+++ b/microservices/AuthApi/Services/UserService.cs
@@ -1,32 +1,32 @@
-using AuthApi.Models;
-using MongoDB.Driver;
-
-namespace AuthApi.Services;
-
-public class UserService
-{
- private readonly IMongoCollection _col;
-
- public UserService(IConfiguration cfg)
- {
- var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
- var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
- var client = new MongoClient(cs);
- var db = client.GetDatabase(dbName);
- _col = db.GetCollection("Users");
-
- var keys = Builders.IndexKeys.Ascending(u => u.Username);
- _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { Unique = true }));
- }
-
- public Task GetByUsernameAsync(string username) =>
- _col.Find(u => u.Username == username).FirstOrDefaultAsync();
-
- public Task CreateAsync(User user) => _col.InsertOneAsync(user);
-
- public Task UpdateAsync(User user) =>
- _col.ReplaceOneAsync(u => u.Id == user.Id, user);
-
- public Task> GetAllAsync() =>
- _col.Find(FilterDefinition.Empty).ToListAsync();
-}
+using AuthApi.Models;
+using MongoDB.Driver;
+
+namespace AuthApi.Services;
+
+public class UserService
+{
+ private readonly IMongoCollection _col;
+
+ public UserService(IConfiguration cfg)
+ {
+ var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
+ var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
+ var client = new MongoClient(cs);
+ var db = client.GetDatabase(dbName);
+ _col = db.GetCollection("Users");
+
+ var keys = Builders.IndexKeys.Ascending(u => u.Username);
+ _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { Unique = true }));
+ }
+
+ public Task GetByUsernameAsync(string username) =>
+ _col.Find(u => u.Username == username).FirstOrDefaultAsync();
+
+ public Task CreateAsync(User user) => _col.InsertOneAsync(user);
+
+ public Task UpdateAsync(User user) =>
+ _col.ReplaceOneAsync(u => u.Id == user.Id, user);
+
+ public Task> GetAllAsync() =>
+ _col.Find(FilterDefinition.Empty).ToListAsync();
+}
diff --git a/microservices/AuthApi/appsettings.Development.json b/microservices/AuthApi/appsettings.Development.json
index ff66ba6..0c208ae 100644
--- a/microservices/AuthApi/appsettings.Development.json
+++ b/microservices/AuthApi/appsettings.Development.json
@@ -1,8 +1,8 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- }
-}
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/microservices/AuthApi/appsettings.json b/microservices/AuthApi/appsettings.json
index 7996a21..40a1fb6 100644
--- a/microservices/AuthApi/appsettings.json
+++ b/microservices/AuthApi/appsettings.json
@@ -1,7 +1,7 @@
-{
- "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5000" } } },
- "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
- "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
- "Logging": { "LogLevel": { "Default": "Information" } },
- "AllowedHosts": "*"
-}
+{
+ "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5000" } } },
+ "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
+ "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
+ "Logging": { "LogLevel": { "Default": "Information" } },
+ "AllowedHosts": "*"
+}
diff --git a/microservices/AuthApi/k8s/deployment.yaml b/microservices/AuthApi/k8s/deployment.yaml
index 68fb120..8d6ff80 100644
--- a/microservices/AuthApi/k8s/deployment.yaml
+++ b/microservices/AuthApi/k8s/deployment.yaml
@@ -1,28 +1,28 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: promiscuity-auth
- labels:
- app: promiscuity-auth
-spec:
- replicas: 2
- selector:
- matchLabels:
- app: promiscuity-auth
- template:
- metadata:
- labels:
- app: promiscuity-auth
- spec:
- containers:
- - name: promiscuity-auth
- image: promiscuity-auth:latest
- imagePullPolicy: IfNotPresent
- ports:
- - containerPort: 5000
- readinessProbe:
- httpGet:
- path: /healthz
- port: 5000
- initialDelaySeconds: 5
- periodSeconds: 10
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: promiscuity-auth
+ labels:
+ app: promiscuity-auth
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: promiscuity-auth
+ template:
+ metadata:
+ labels:
+ app: promiscuity-auth
+ spec:
+ containers:
+ - name: promiscuity-auth
+ image: promiscuity-auth:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 5000
+ readinessProbe:
+ httpGet:
+ path: /healthz
+ port: 5000
+ initialDelaySeconds: 5
+ periodSeconds: 10
diff --git a/microservices/AuthApi/k8s/service.yaml b/microservices/AuthApi/k8s/service.yaml
index 10bbe42..8d90a50 100644
--- a/microservices/AuthApi/k8s/service.yaml
+++ b/microservices/AuthApi/k8s/service.yaml
@@ -1,15 +1,15 @@
-apiVersion: v1
-kind: Service
-metadata:
- name: promiscuity-auth
- labels:
- app: promiscuity-auth
-spec:
- selector:
- app: promiscuity-auth
- type: NodePort
- ports:
- - name: http
- port: 80 # cluster port
- targetPort: 5000 # container port
- nodePort: 30080 # same external port you've been using
+apiVersion: v1
+kind: Service
+metadata:
+ name: promiscuity-auth
+ labels:
+ app: promiscuity-auth
+spec:
+ selector:
+ app: promiscuity-auth
+ type: NodePort
+ ports:
+ - name: http
+ port: 80 # cluster port
+ targetPort: 5000 # container port
+ nodePort: 30080 # same external port you've been using
diff --git a/microservices/CharacterApi/CharacterApi.csproj b/microservices/CharacterApi/CharacterApi.csproj
index ea4d0bc..0cb0950 100644
--- a/microservices/CharacterApi/CharacterApi.csproj
+++ b/microservices/CharacterApi/CharacterApi.csproj
@@ -1,16 +1,16 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
-
-
-
-
-
-
-
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/microservices/CharacterApi/Controllers/CharactersController.cs b/microservices/CharacterApi/Controllers/CharactersController.cs
index 8b3c8a7..f2abb3c 100644
--- a/microservices/CharacterApi/Controllers/CharactersController.cs
+++ b/microservices/CharacterApi/Controllers/CharactersController.cs
@@ -7,11 +7,11 @@ using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
-
-namespace CharacterApi.Controllers;
-
-[ApiController]
-[Route("api/[controller]")]
+
+namespace CharacterApi.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
public class CharactersController : ControllerBase
{
private static readonly TimeSpan PresenceTimeout = TimeSpan.FromSeconds(45);
@@ -27,18 +27,18 @@ public class CharactersController : ControllerBase
_configuration = configuration;
_logger = logger;
}
-
- [HttpPost]
- [Authorize(Roles = "USER,SUPER")]
+
+ [HttpPost]
+ [Authorize(Roles = "USER,SUPER")]
public async Task Create([FromBody] CreateCharacterRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest("Name required");
-
- var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
- if (string.IsNullOrWhiteSpace(userId))
- return Unauthorized();
-
+
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (string.IsNullOrWhiteSpace(userId))
+ return Unauthorized();
+
var character = new Character
{
OwnerUserId = userId,
@@ -60,9 +60,9 @@ public class CharactersController : ControllerBase
return Ok(character);
}
-
- [HttpGet]
- [Authorize(Roles = "USER,SUPER")]
+
+ [HttpGet]
+ [Authorize(Roles = "USER,SUPER")]
public async Task ListMine()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -269,16 +269,16 @@ public class CharactersController : ControllerBase
[HttpDelete("{id}")]
[Authorize(Roles = "USER,SUPER")]
public async Task Delete(string id)
- {
- var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
- if (string.IsNullOrWhiteSpace(userId))
- return Unauthorized();
-
- var allowAnyOwner = User.IsInRole("SUPER");
- var deleted = await _characters.DeleteForOwnerAsync(id, userId, allowAnyOwner);
- if (!deleted)
- return NotFound();
-
- return Ok("Deleted");
- }
-}
+ {
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (string.IsNullOrWhiteSpace(userId))
+ return Unauthorized();
+
+ var allowAnyOwner = User.IsInRole("SUPER");
+ var deleted = await _characters.DeleteForOwnerAsync(id, userId, allowAnyOwner);
+ if (!deleted)
+ return NotFound();
+
+ return Ok("Deleted");
+ }
+}
diff --git a/microservices/CharacterApi/DOCUMENTS.md b/microservices/CharacterApi/DOCUMENTS.md
index 7e4d5e1..6c77538 100644
--- a/microservices/CharacterApi/DOCUMENTS.md
+++ b/microservices/CharacterApi/DOCUMENTS.md
@@ -1,9 +1,9 @@
-# CharacterApi document shapes
-
-This service expects JSON request bodies for character creation and stores
-character documents in MongoDB.
-
-Inbound JSON documents
+# CharacterApi document shapes
+
+This service expects JSON request bodies for character creation and stores
+character documents in MongoDB.
+
+Inbound JSON documents
- CreateCharacterRequest (`POST /api/characters`)
```json
{
@@ -19,8 +19,8 @@ Inbound JSON documents
}
}
```
-
-Stored documents (MongoDB)
+
+Stored documents (MongoDB)
- Character
```json
{
diff --git a/microservices/CharacterApi/Models/Character.cs b/microservices/CharacterApi/Models/Character.cs
index e4e613d..9fefa4f 100644
--- a/microservices/CharacterApi/Models/Character.cs
+++ b/microservices/CharacterApi/Models/Character.cs
@@ -1,16 +1,16 @@
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace CharacterApi.Models;
-
-public class Character
-{
- [BsonId]
- [BsonRepresentation(BsonType.ObjectId)]
- public string? Id { get; set; }
-
- public string OwnerUserId { get; set; } = string.Empty;
-
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace CharacterApi.Models;
+
+public class Character
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ public string OwnerUserId { get; set; } = string.Empty;
+
public string Name { get; set; } = string.Empty;
public Coord Coord { get; set; } = new();
diff --git a/microservices/CharacterApi/Models/CreateCharacterRequest.cs b/microservices/CharacterApi/Models/CreateCharacterRequest.cs
index 0cb8f46..8033817 100644
--- a/microservices/CharacterApi/Models/CreateCharacterRequest.cs
+++ b/microservices/CharacterApi/Models/CreateCharacterRequest.cs
@@ -1,6 +1,6 @@
-namespace CharacterApi.Models;
-
-public class CreateCharacterRequest
-{
- public string Name { get; set; } = string.Empty;
-}
+namespace CharacterApi.Models;
+
+public class CreateCharacterRequest
+{
+ public string Name { get; set; } = string.Empty;
+}
diff --git a/microservices/CharacterApi/Models/VisibleLocation.cs b/microservices/CharacterApi/Models/VisibleLocation.cs
index d4ee262..830028f 100644
--- a/microservices/CharacterApi/Models/VisibleLocation.cs
+++ b/microservices/CharacterApi/Models/VisibleLocation.cs
@@ -1,18 +1,18 @@
-using MongoDB.Bson.Serialization.Attributes;
-using MongoDB.Bson;
-
-namespace CharacterApi.Models;
-
-[BsonIgnoreExtraElements]
+using MongoDB.Bson.Serialization.Attributes;
+using MongoDB.Bson;
+
+namespace CharacterApi.Models;
+
+[BsonIgnoreExtraElements]
public class VisibleLocation
{
- [BsonId]
- [BsonRepresentation(BsonType.ObjectId)]
- public string? Id { get; set; }
-
- [BsonElement("name")]
- public string Name { get; set; } = string.Empty;
-
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ [BsonElement("name")]
+ public string Name { get; set; } = string.Empty;
+
[BsonElement("coord")]
public LocationCoord Coord { get; set; } = new();
diff --git a/microservices/CharacterApi/Program.cs b/microservices/CharacterApi/Program.cs
index 32c6612..93cedcc 100644
--- a/microservices/CharacterApi/Program.cs
+++ b/microservices/CharacterApi/Program.cs
@@ -4,61 +4,61 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Text;
-
-var builder = WebApplication.CreateBuilder(args);
+
+var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// DI
builder.Services.AddSingleton();
builder.Services.AddHttpClient();
-
-// Swagger + JWT auth in Swagger
-builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen(c =>
-{
- c.SwaggerDoc("v1", new OpenApiInfo { Title = "Character API", Version = "v1" });
- c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
- {
- Type = SecuritySchemeType.Http,
- Scheme = "bearer",
- BearerFormat = "JWT",
- Description = "Paste your access token here (no 'Bearer ' prefix needed)."
- });
- c.AddSecurityRequirement(new OpenApiSecurityRequirement
- {
- {
- new OpenApiSecurityScheme
- {
- Reference = new OpenApiReference
- { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
- },
- Array.Empty()
- }
- });
-});
-
-// AuthN/JWT
-var cfg = builder.Configuration;
-var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing");
-var issuer = cfg["Jwt:Issuer"] ?? "promiscuity";
-var aud = cfg["Jwt:Audience"] ?? issuer;
-
-builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddJwtBearer(o =>
- {
- o.TokenValidationParameters = new TokenValidationParameters
- {
- ValidateIssuer = true, ValidIssuer = issuer,
- ValidateAudience = true, ValidAudience = aud,
- ValidateIssuerSigningKey = true,
- IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
- ValidateLifetime = true,
- ClockSkew = TimeSpan.FromSeconds(30)
- };
- });
-
-builder.Services.AddAuthorization();
-
+
+// Swagger + JWT auth in Swagger
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo { Title = "Character API", Version = "v1" });
+ c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme
+ {
+ Type = SecuritySchemeType.Http,
+ Scheme = "bearer",
+ BearerFormat = "JWT",
+ Description = "Paste your access token here (no 'Bearer ' prefix needed)."
+ });
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" }
+ },
+ Array.Empty()
+ }
+ });
+});
+
+// AuthN/JWT
+var cfg = builder.Configuration;
+var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing");
+var issuer = cfg["Jwt:Issuer"] ?? "promiscuity";
+var aud = cfg["Jwt:Audience"] ?? issuer;
+
+builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddJwtBearer(o =>
+ {
+ o.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true, ValidIssuer = issuer,
+ ValidateAudience = true, ValidAudience = aud,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromSeconds(30)
+ };
+ });
+
+builder.Services.AddAuthorization();
+
var app = builder.Build();
app.UseExceptionHandler(errorApp =>
@@ -98,11 +98,11 @@ app.UseExceptionHandler(errorApp =>
app.MapGet("/healthz", () => Results.Ok("ok"));
app.UseSwagger();
app.UseSwaggerUI(o =>
-{
- o.SwaggerEndpoint("/swagger/v1/swagger.json", "Character API v1");
- o.RoutePrefix = "swagger";
-});
-app.UseAuthentication();
-app.UseAuthorization();
-app.MapControllers();
-app.Run();
+{
+ o.SwaggerEndpoint("/swagger/v1/swagger.json", "Character API v1");
+ o.RoutePrefix = "swagger";
+});
+app.UseAuthentication();
+app.UseAuthorization();
+app.MapControllers();
+app.Run();
diff --git a/microservices/CharacterApi/Properties/launchSettings.json b/microservices/CharacterApi/Properties/launchSettings.json
index 703262b..374e9df 100644
--- a/microservices/CharacterApi/Properties/launchSettings.json
+++ b/microservices/CharacterApi/Properties/launchSettings.json
@@ -1,12 +1,12 @@
-{
- "profiles": {
- "CharacterApi": {
- "commandName": "Project",
- "launchBrowser": true,
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- },
- "applicationUrl": "https://localhost:50784;http://localhost:50785"
- }
- }
+{
+ "profiles": {
+ "CharacterApi": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:50784;http://localhost:50785"
+ }
+ }
}
\ No newline at end of file
diff --git a/microservices/CharacterApi/appsettings.Development.json b/microservices/CharacterApi/appsettings.Development.json
index 8b4646a..4429d79 100644
--- a/microservices/CharacterApi/appsettings.Development.json
+++ b/microservices/CharacterApi/appsettings.Development.json
@@ -1,4 +1,4 @@
-{
+{
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Services": { "LocationsApiBaseUrl": "http://localhost:5002" },
diff --git a/microservices/CharacterApi/appsettings.json b/microservices/CharacterApi/appsettings.json
index 23b5468..5796d18 100644
--- a/microservices/CharacterApi/appsettings.json
+++ b/microservices/CharacterApi/appsettings.json
@@ -1,4 +1,4 @@
-{
+{
"Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } },
"MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
"Services": { "LocationsApiBaseUrl": "https://ploc.ranaze.com" },
diff --git a/microservices/CharacterApi/k8s/deployment.yaml b/microservices/CharacterApi/k8s/deployment.yaml
index 3bee9a4..d92297c 100644
--- a/microservices/CharacterApi/k8s/deployment.yaml
+++ b/microservices/CharacterApi/k8s/deployment.yaml
@@ -1,28 +1,28 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: promiscuity-character
- labels:
- app: promiscuity-character
-spec:
- replicas: 2
- selector:
- matchLabels:
- app: promiscuity-character
- template:
- metadata:
- labels:
- app: promiscuity-character
- spec:
- containers:
- - name: promiscuity-character
- image: promiscuity-character:latest
- imagePullPolicy: IfNotPresent
- ports:
- - containerPort: 5001
- readinessProbe:
- httpGet:
- path: /healthz
- port: 5001
- initialDelaySeconds: 5
- periodSeconds: 10
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: promiscuity-character
+ labels:
+ app: promiscuity-character
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: promiscuity-character
+ template:
+ metadata:
+ labels:
+ app: promiscuity-character
+ spec:
+ containers:
+ - name: promiscuity-character
+ image: promiscuity-character:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 5001
+ readinessProbe:
+ httpGet:
+ path: /healthz
+ port: 5001
+ initialDelaySeconds: 5
+ periodSeconds: 10
diff --git a/microservices/CharacterApi/k8s/service.yaml b/microservices/CharacterApi/k8s/service.yaml
index c631062..4a16f0e 100644
--- a/microservices/CharacterApi/k8s/service.yaml
+++ b/microservices/CharacterApi/k8s/service.yaml
@@ -1,15 +1,15 @@
-apiVersion: v1
-kind: Service
-metadata:
- name: promiscuity-character
- labels:
- app: promiscuity-character
-spec:
- selector:
- app: promiscuity-character
- type: NodePort
- ports:
- - name: http
- port: 80 # cluster port
- targetPort: 5001 # container port
- nodePort: 30081 # external port
+apiVersion: v1
+kind: Service
+metadata:
+ name: promiscuity-character
+ labels:
+ app: promiscuity-character
+spec:
+ selector:
+ app: promiscuity-character
+ type: NodePort
+ ports:
+ - name: http
+ port: 80 # cluster port
+ targetPort: 5001 # container port
+ nodePort: 30081 # external port
diff --git a/microservices/CraftingApi/Controllers/CraftingController.cs b/microservices/CraftingApi/Controllers/CraftingController.cs
index a30a82f..964a125 100644
--- a/microservices/CraftingApi/Controllers/CraftingController.cs
+++ b/microservices/CraftingApi/Controllers/CraftingController.cs
@@ -89,6 +89,7 @@ public class CraftingController : ControllerBase
CraftingStore.CraftStatus.RecipeNotFound => NotFound("Recipe not found"),
CraftingStore.CraftStatus.MissingStation => Conflict("Required crafting station is not available"),
CraftingStore.CraftStatus.MissingInputs => Conflict(new { missingRequirements = result.MissingRequirements }),
+ CraftingStore.CraftStatus.InventoryFull => Conflict(new { missingRequirements = result.MissingRequirements }),
CraftingStore.CraftStatus.InvalidRecipe => BadRequest(new { missingRequirements = result.MissingRequirements }),
_ => Ok(new CraftRecipeResponse
{
diff --git a/microservices/CraftingApi/Services/CraftingStore.cs b/microservices/CraftingApi/Services/CraftingStore.cs
index 17095a8..3171a0a 100644
--- a/microservices/CraftingApi/Services/CraftingStore.cs
+++ b/microservices/CraftingApi/Services/CraftingStore.cs
@@ -99,7 +99,7 @@ public class CraftingStore
{
var recipes = await _recipes.Find(r => r.Enabled).SortBy(r => r.Category).ThenBy(r => r.Name).ToListAsync();
var items = await GetCharacterItemsAsync(character.CharacterId);
- var itemTotals = items.GroupBy(i => i.ItemKey).ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity), StringComparer.Ordinal);
+ var itemTotals = GetCraftableItemTotals(items);
var location = await GetLocationAtCoordAsync(character.CoordX, character.CoordY);
return recipes.Select(recipe =>
@@ -126,7 +126,7 @@ public class CraftingStore
return new CraftAttemptResult { Status = CraftStatus.MissingStation };
var items = await GetCharacterItemsAsync(character.CharacterId);
- var totals = items.GroupBy(i => i.ItemKey).ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity), StringComparer.Ordinal);
+ var totals = GetCraftableItemTotals(items);
var missing = GetMissingRequirements(recipe, totals, location, craftCount);
if (missing.Count > 0)
return new CraftAttemptResult { Status = CraftStatus.MissingInputs, MissingRequirements = missing };
@@ -139,6 +139,10 @@ public class CraftingStore
return new CraftAttemptResult { Status = CraftStatus.InvalidRecipe, MissingRequirements = [$"Missing item definition for output '{output.ItemKey}'"] };
}
+ var capacityError = GetOutputCapacityError(recipe, craftCount, items, definitionMap);
+ if (capacityError is not null)
+ return new CraftAttemptResult { Status = CraftStatus.InventoryFull, MissingRequirements = [capacityError] };
+
foreach (var input in recipe.Inputs)
await ConsumeItemKeyAsync(character, input.ItemKey, input.Quantity * craftCount);
@@ -157,6 +161,12 @@ public class CraftingStore
private async Task> GetCharacterItemsAsync(string characterId) =>
await _items.Find(i => i.OwnerType == CharacterOwnerType && i.OwnerId == characterId).SortBy(i => i.Slot).ThenBy(i => i.ItemKey).ToListAsync();
+ private static Dictionary GetCraftableItemTotals(IEnumerable items) =>
+ items
+ .Where(i => i.EquippedSlot is null)
+ .GroupBy(i => i.ItemKey)
+ .ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity), StringComparer.Ordinal);
+
private async Task GetLocationAtCoordAsync(int x, int y) =>
await _locations.Find(l => l.Coord.X == x && l.Coord.Y == y).FirstOrDefaultAsync();
@@ -216,6 +226,74 @@ public class CraftingStore
return string.Equals(recipe.StationType, stationType, StringComparison.Ordinal);
}
+ private static string? GetOutputCapacityError(CraftingRecipe recipe, int craftCount, List currentItems, IReadOnlyDictionary definitions)
+ {
+ var simulatedItems = currentItems
+ .Select(item => new SimulatedInventoryItem
+ {
+ ItemKey = item.ItemKey,
+ Quantity = item.Quantity,
+ Slot = item.Slot,
+ EquippedSlot = item.EquippedSlot,
+ CreatedUtc = item.CreatedUtc
+ })
+ .ToList();
+
+ foreach (var input in recipe.Inputs)
+ SimulateConsume(simulatedItems, input.ItemKey, input.Quantity * craftCount);
+
+ var usedSlots = simulatedItems
+ .Where(i => i.Slot.HasValue)
+ .Select(i => i.Slot!.Value)
+ .ToHashSet();
+ var openSlots = Enumerable.Range(0, InventorySlotCount).Count(slot => !usedSlots.Contains(slot));
+
+ foreach (var outputGroup in recipe.Outputs.GroupBy(output => output.ItemKey, StringComparer.Ordinal))
+ {
+ var itemKey = outputGroup.Key;
+ var requiredQuantity = outputGroup.Sum(output => output.Quantity * craftCount);
+ var definition = definitions[itemKey];
+
+ if (definition.Stackable)
+ {
+ var existingSpace = simulatedItems
+ .Where(i => i.EquippedSlot is null && i.Slot.HasValue && i.ItemKey == itemKey)
+ .Sum(i => Math.Max(0, definition.MaxStackSize - i.Quantity));
+ var remaining = Math.Max(0, requiredQuantity - existingSpace);
+ var slotsNeeded = (int)Math.Ceiling(remaining / (double)Math.Max(1, definition.MaxStackSize));
+ if (slotsNeeded > openSlots)
+ return $"Not enough inventory space for output '{itemKey}'";
+ openSlots -= slotsNeeded;
+ continue;
+ }
+
+ if (requiredQuantity > openSlots)
+ return $"Not enough inventory space for output '{itemKey}'";
+ openSlots -= requiredQuantity;
+ }
+
+ return null;
+ }
+
+ private static void SimulateConsume(List items, string itemKey, int quantity)
+ {
+ var remaining = quantity;
+ foreach (var item in items
+ .Where(i => i.EquippedSlot is null && i.ItemKey == itemKey)
+ .OrderBy(i => i.Slot)
+ .ThenBy(i => i.CreatedUtc))
+ {
+ if (remaining <= 0)
+ break;
+
+ var consumed = Math.Min(remaining, item.Quantity);
+ item.Quantity -= consumed;
+ remaining -= consumed;
+ }
+
+ items.RemoveAll(i => i.Quantity <= 0);
+ }
+
private async Task ConsumeItemKeyAsync(CharacterAccessResult character, string itemKey, int quantity)
{
var remaining = quantity;
@@ -302,7 +380,7 @@ public class CraftingStore
{
var items = await GetCharacterItemsAsync(characterId);
var used = items.Where(i => i.Slot.HasValue).Select(i => i.Slot!.Value).ToHashSet();
- for (var slot = 0; slot < 6; slot++)
+ for (var slot = 0; slot < InventorySlotCount; slot++)
{
if (!used.Contains(slot))
return slot;
@@ -344,7 +422,23 @@ public class CraftingStore
RecipeNotFound,
MissingInputs,
MissingStation,
- InvalidRecipe
+ InvalidRecipe,
+ InventoryFull
+ }
+
+ private const int InventorySlotCount = 6;
+
+ private class SimulatedInventoryItem
+ {
+ public string ItemKey { get; set; } = string.Empty;
+
+ public int Quantity { get; set; }
+
+ public int? Slot { get; set; }
+
+ public string? EquippedSlot { get; set; }
+
+ public DateTime CreatedUtc { get; set; }
}
[BsonIgnoreExtraElements]
diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs
index ecd1067..df74b4a 100644
--- a/microservices/LocationsApi/Models/Location.cs
+++ b/microservices/LocationsApi/Models/Location.cs
@@ -1,17 +1,17 @@
-using MongoDB.Bson;
-using MongoDB.Bson.Serialization.Attributes;
-
-namespace LocationsApi.Models;
-
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace LocationsApi.Models;
+
public class Location
{
- [BsonId]
- [BsonRepresentation(BsonType.ObjectId)]
- public string? Id { get; set; }
-
- [BsonElement("name")]
- public string Name { get; set; } = string.Empty;
-
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ [BsonElement("name")]
+ public string Name { get; set; } = string.Empty;
+
[BsonElement("coord")]
public required Coord Coord { get; set; }
@@ -26,10 +26,10 @@ public class Location
[BsonElement("locationObject")]
public LocationObject? LocationObject { get; set; }
-
- [BsonElement("locationObjectResolved")]
- public bool LocationObjectResolved { get; set; }
-
- [BsonElement("createdUtc")]
- public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
-}
+
+ [BsonElement("locationObjectResolved")]
+ public bool LocationObjectResolved { get; set; }
+
+ [BsonElement("createdUtc")]
+ public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs
index 4c06a63..2bcee7c 100644
--- a/microservices/LocationsApi/Services/LocationStore.cs
+++ b/microservices/LocationsApi/Services/LocationStore.cs
@@ -1,9 +1,9 @@
-using LocationsApi.Models;
-using MongoDB.Bson;
-using MongoDB.Driver;
-
-namespace LocationsApi.Services;
-
+using LocationsApi.Models;
+using MongoDB.Bson;
+using MongoDB.Driver;
+
+namespace LocationsApi.Services;
+
public class LocationStore
{
private readonly IMongoCollection _col;
@@ -19,7 +19,7 @@ public class LocationStore
{
var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
- var client = new MongoClient(cs);
+ var client = new MongoClient(cs);
var db = client.GetDatabase(dbName);
var collectionName = "Locations";
EnsureLocationSchema(db, collectionName);
@@ -32,33 +32,33 @@ public class LocationStore
EnsureOriginLocation();
}
-
- private static void EnsureLocationSchema(IMongoDatabase db, string collectionName)
- {
- var validator = new BsonDocument
- {
- {
- "$jsonSchema", new BsonDocument
- {
- { "bsonType", "object" },
+
+ private static void EnsureLocationSchema(IMongoDatabase db, string collectionName)
+ {
+ var validator = new BsonDocument
+ {
+ {
+ "$jsonSchema", new BsonDocument
+ {
+ { "bsonType", "object" },
{ "required", new BsonArray { "name", "coord", "biomeKey", "elevation", "createdUtc" } },
{
"properties", new BsonDocument
{
{ "name", new BsonDocument { { "bsonType", "string" } } },
- {
- "coord", new BsonDocument
- {
- { "bsonType", "object" },
- { "required", new BsonArray { "x", "y" } },
- {
- "properties", new BsonDocument
- {
- { "x", new BsonDocument { { "bsonType", "int" } } },
- { "y", new BsonDocument { { "bsonType", "int" } } }
- }
- }
- }
+ {
+ "coord", new BsonDocument
+ {
+ { "bsonType", "object" },
+ { "required", new BsonArray { "x", "y" } },
+ {
+ "properties", new BsonDocument
+ {
+ { "x", new BsonDocument { { "bsonType", "int" } } },
+ { "y", new BsonDocument { { "bsonType", "int" } } }
+ }
+ }
+ }
},
{ "biomeKey", new BsonDocument { { "bsonType", "string" } } },
{ "elevation", new BsonDocument { { "bsonType", "int" } } },
@@ -118,42 +118,42 @@ public class LocationStore
}
}
}
- };
-
- var collections = db.ListCollectionNames().ToList();
- if (!collections.Contains(collectionName))
- {
- var createCommand = new BsonDocument
- {
- { "create", collectionName },
- { "validator", validator },
- { "validationAction", "error" }
- };
- db.RunCommand(createCommand);
- return;
- }
-
- var command = new BsonDocument
- {
- { "collMod", collectionName },
- { "validator", validator },
- { "validationAction", "error" }
- };
- db.RunCommand(command);
- }
-
- public Task CreateAsync(Location location) => _col.InsertOneAsync(location);
-
- public Task> GetAllAsync() =>
- _col.Find(Builders.Filter.Empty).ToListAsync();
-
- public async Task DeleteAsync(string id)
- {
- var filter = Builders.Filter.Eq(l => l.Id, id);
- var result = await _col.DeleteOneAsync(filter);
- return result.DeletedCount > 0;
- }
-
+ };
+
+ var collections = db.ListCollectionNames().ToList();
+ if (!collections.Contains(collectionName))
+ {
+ var createCommand = new BsonDocument
+ {
+ { "create", collectionName },
+ { "validator", validator },
+ { "validationAction", "error" }
+ };
+ db.RunCommand(createCommand);
+ return;
+ }
+
+ var command = new BsonDocument
+ {
+ { "collMod", collectionName },
+ { "validator", validator },
+ { "validationAction", "error" }
+ };
+ db.RunCommand(command);
+ }
+
+ public Task CreateAsync(Location location) => _col.InsertOneAsync(location);
+
+ public Task> GetAllAsync() =>
+ _col.Find(Builders.Filter.Empty).ToListAsync();
+
+ public async Task DeleteAsync(string id)
+ {
+ var filter = Builders.Filter.Eq(l => l.Id, id);
+ var result = await _col.DeleteOneAsync(filter);
+ return result.DeletedCount > 0;
+ }
+
public async Task UpdateNameAsync(string id, string name)
{
var filter = Builders.Filter.Eq(l => l.Id, id);
@@ -419,10 +419,10 @@ public class LocationStore
{
_col.InsertOne(origin);
}
- catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
- {
- // Another instance seeded it first.
- }
+ catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
+ {
+ // Another instance seeded it first.
+ }
}
private static string NormalizeItemKey(string itemKey) => itemKey.Trim().ToLowerInvariant();
diff --git a/microservices/README.md b/microservices/README.md
index bb28261..aba10ae 100644
--- a/microservices/README.md
+++ b/microservices/README.md
@@ -11,4 +11,4 @@
- CharacterApi: `CharacterApi/README.md`
- InventoryApi: `InventoryApi/README.md`
- LocationsApi: `LocationsApi/README.md`
-
+
diff --git a/microservices/micro-services.sln b/microservices/micro-services.sln
index adb2f3e..b83dae9 100644
--- a/microservices/micro-services.sln
+++ b/microservices/micro-services.sln
@@ -1,9 +1,9 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.5.2.0
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthApi", "AuthApi\AuthApi.csproj", "{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}"
-EndProject
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.2.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthApi", "AuthApi\AuthApi.csproj", "{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CharacterApi", "CharacterApi\CharacterApi.csproj", "{1572BA36-8EFC-4472-BE74-0676B593AED9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocationsApi", "LocationsApi\LocationsApi.csproj", "{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}"
@@ -21,11 +21,11 @@ Global
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|Any CPU.Build.0 = Release|Any CPU
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|Any CPU.Build.0 = Release|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -51,10 +51,10 @@ Global
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {F82C87CC-7411-493D-A138-491A81FBCC32}
- EndGlobalSection
-EndGlobal
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {F82C87CC-7411-493D-A138-491A81FBCC32}
+ EndGlobalSection
+EndGlobal