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