diff --git a/.gitea/workflows/deploy-auth.yml b/.gitea/workflows/deploy-auth.yml
index 747b4d0..303c271 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 }}
@@ -82,23 +82,32 @@ jobs:
mkdir -p /tmp/kube
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
+ # -----------------------------
+ # Ensure namespace exists
+ # -----------------------------
+ - name: Create namespace if missing
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl create namespace promiscuity-auth --dry-run=client -o yaml | kubectl apply -f -
+
# -----------------------------
# 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 0c48550..66a8dc0 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 }}
@@ -83,21 +83,30 @@ jobs:
printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
# -----------------------------
- # Apply Kubernetes manifests
+ # Ensure namespace exists
# -----------------------------
- - name: Apply Character deployment & service
+ - name: Create namespace if missing
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
+ kubectl create namespace promiscuity-character --dry-run=client -o yaml | kubectl apply -f -
# -----------------------------
- # Rollout restart & wait
+ # Apply Kubernetes manifests
# -----------------------------
- - 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/deploy-locations.yml b/.gitea/workflows/deploy-locations.yml
new file mode 100644
index 0000000..0b38df9
--- /dev/null
+++ b/.gitea/workflows/deploy-locations.yml
@@ -0,0 +1,112 @@
+name: Deploy Promiscuity Locations API
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch: {}
+
+jobs:
+ deploy:
+ runs-on: self-hosted
+
+ env:
+ IMAGE_NAME: promiscuity-locations:latest
+ IMAGE_TAR: /tmp/promiscuity-locations.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/LocationsApi
+ 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-locations.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-locations.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-locations.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 }}
+ run: |
+ mkdir -p /tmp/kube
+ printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config
+
+ # -----------------------------
+ # Ensure namespace exists
+ # -----------------------------
+ - name: Create namespace if missing
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl create namespace promiscuity-locations --dry-run=client -o yaml | kubectl apply -f -
+
+ # -----------------------------
+ # Apply Kubernetes manifests
+ # -----------------------------
+ - name: Apply Locations deployment & service
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl apply -f microservices/LocationsApi/k8s/deployment.yaml -n promiscuity-locations
+ kubectl apply -f microservices/LocationsApi/k8s/service.yaml -n promiscuity-locations
+
+ # -----------------------------
+ # Rollout restart & wait
+ # -----------------------------
+ - name: Restart Locations deployment
+ env:
+ KUBECONFIG: /tmp/kube/config
+ run: |
+ kubectl rollout restart deployment/promiscuity-locations -n promiscuity-locations
+ kubectl rollout status deployment/promiscuity-locations -n promiscuity-locations
diff --git a/.gitea/workflows/k8s-smoke-test.yml b/.gitea/workflows/k8s-smoke-test.yml
index 53e7d60..93eea21 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
new file mode 100644
index 0000000..50383d5
--- /dev/null
+++ b/README.md
@@ -0,0 +1,12 @@
+# Promiscuity
+
+## Microservices
+- Auth Microservice Swagger: https://pauth.ranaze.com/swagger/index.html
+- Character Microservice Swagger: https://pchar.ranaze.com/swagger/index.html
+- Microservices README: microservices/README.md
+
+## Test users
+- `SUPER/SUPER` - Super User
+- `test1/test1` - Super User
+- `test3/test3` - User
+
diff --git a/README.txt b/README.txt
deleted file mode 100644
index 7d92c04..0000000
--- a/README.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-Auth Microservice swagger is accessible at https://pauth.ranaze.com/swagger/index.html
-Character Microservice swagger is accessible at https://pchar.ranaze.com/swagger/index.html
-
-Test Users:
- SUPER/SUPER - Super User
- test1/test1 - Super User
- test3/test3 - User
-
\ No newline at end of file
diff --git a/game/.gitignore b/game/.gitignore
index 40ec496..f525d3d 100644
--- a/game/.gitignore
+++ b/game/.gitignore
@@ -1,17 +1,17 @@
-# Godot 4+ specific ignores
-.godot/
-.nomedia
-
-# Godot-specific ignores
-.import/
-export.cfg
-export_credentials.cfg
-*.tmp
-
-# Imported translations (automatically generated from CSV files)
-*.translation
-
-# Mono-specific ignores
-.mono/
-data_*/
+# Godot 4+ specific ignores
+.godot/
+.nomedia
+
+# Godot-specific ignores
+.import/
+export.cfg
+export_credentials.cfg
+*.tmp
+
+# Imported translations (automatically generated from CSV files)
+*.translation
+
+# Mono-specific ignores
+.mono/
+data_*/
mono_crash.*.json
\ No newline at end of file
diff --git a/game/assets/audio/jump.ogg.import b/game/assets/audio/jump.ogg.import
index 1e15ee3..d0a637d 100644
--- a/game/assets/audio/jump.ogg.import
+++ b/game/assets/audio/jump.ogg.import
@@ -1,19 +1,19 @@
-[remap]
-
-importer="oggvorbisstr"
-type="AudioStreamOggVorbis"
-uid="uid://de2e8sy4x724m"
-path="res://.godot/imported/jump.ogg-09aff86a6f79a8fce2febb69902962cf.oggvorbisstr"
-
-[deps]
-
-source_file="res://assets/audio/jump.ogg"
-dest_files=["res://.godot/imported/jump.ogg-09aff86a6f79a8fce2febb69902962cf.oggvorbisstr"]
-
-[params]
-
-loop=false
-loop_offset=0
-bpm=0
-beat_count=0
-bar_beats=4
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://de2e8sy4x724m"
+path="res://.godot/imported/jump.ogg-09aff86a6f79a8fce2febb69902962cf.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/jump.ogg"
+dest_files=["res://.godot/imported/jump.ogg-09aff86a6f79a8fce2febb69902962cf.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/game/assets/audio/silly-menu-hover-test.ogg.import b/game/assets/audio/silly-menu-hover-test.ogg.import
index 0a47544..018ec64 100644
--- a/game/assets/audio/silly-menu-hover-test.ogg.import
+++ b/game/assets/audio/silly-menu-hover-test.ogg.import
@@ -1,19 +1,19 @@
-[remap]
-
-importer="oggvorbisstr"
-type="AudioStreamOggVorbis"
-uid="uid://64dplcgx2icb"
-path="res://.godot/imported/silly-menu-hover-test.ogg-101de051c9810c756b28483653a4c618.oggvorbisstr"
-
-[deps]
-
-source_file="res://assets/audio/silly-menu-hover-test.ogg"
-dest_files=["res://.godot/imported/silly-menu-hover-test.ogg-101de051c9810c756b28483653a4c618.oggvorbisstr"]
-
-[params]
-
-loop=false
-loop_offset=0
-bpm=0
-beat_count=0
-bar_beats=4
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://64dplcgx2icb"
+path="res://.godot/imported/silly-menu-hover-test.ogg-101de051c9810c756b28483653a4c618.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/silly-menu-hover-test.ogg"
+dest_files=["res://.godot/imported/silly-menu-hover-test.ogg-101de051c9810c756b28483653a4c618.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/game/assets/audio/silly-test.ogg.import b/game/assets/audio/silly-test.ogg.import
index db3d768..da1cc68 100644
--- a/game/assets/audio/silly-test.ogg.import
+++ b/game/assets/audio/silly-test.ogg.import
@@ -1,19 +1,19 @@
-[remap]
-
-importer="oggvorbisstr"
-type="AudioStreamOggVorbis"
-uid="uid://txgki0ijeuud"
-path="res://.godot/imported/silly-test.ogg-4a08df8a26b9ee1e5d13235e013c7cfc.oggvorbisstr"
-
-[deps]
-
-source_file="res://assets/audio/silly-test.ogg"
-dest_files=["res://.godot/imported/silly-test.ogg-4a08df8a26b9ee1e5d13235e013c7cfc.oggvorbisstr"]
-
-[params]
-
-loop=false
-loop_offset=0
-bpm=0
-beat_count=0
-bar_beats=4
+[remap]
+
+importer="oggvorbisstr"
+type="AudioStreamOggVorbis"
+uid="uid://txgki0ijeuud"
+path="res://.godot/imported/silly-test.ogg-4a08df8a26b9ee1e5d13235e013c7cfc.oggvorbisstr"
+
+[deps]
+
+source_file="res://assets/audio/silly-test.ogg"
+dest_files=["res://.godot/imported/silly-test.ogg-4a08df8a26b9ee1e5d13235e013c7cfc.oggvorbisstr"]
+
+[params]
+
+loop=false
+loop_offset=0
+bpm=0
+beat_count=0
+bar_beats=4
diff --git a/game/assets/fonts/PlayfairDisplay-VariableFont_wght.ttf.import b/game/assets/fonts/PlayfairDisplay-VariableFont_wght.ttf.import
index ad2f4cf..f1dc51a 100644
--- a/game/assets/fonts/PlayfairDisplay-VariableFont_wght.ttf.import
+++ b/game/assets/fonts/PlayfairDisplay-VariableFont_wght.ttf.import
@@ -1,36 +1,36 @@
-[remap]
-
-importer="font_data_dynamic"
-type="FontFile"
-uid="uid://m5ceou0rk6j6"
-path="res://.godot/imported/PlayfairDisplay-VariableFont_wght.ttf-fbc765a7962e1c71b0eb2c53d6eb2a10.fontdata"
-
-[deps]
-
-source_file="res://assets/fonts/PlayfairDisplay-VariableFont_wght.ttf"
-dest_files=["res://.godot/imported/PlayfairDisplay-VariableFont_wght.ttf-fbc765a7962e1c71b0eb2c53d6eb2a10.fontdata"]
-
-[params]
-
-Rendering=null
-antialiasing=1
-generate_mipmaps=false
-disable_embedded_bitmaps=true
-multichannel_signed_distance_field=false
-msdf_pixel_range=8
-msdf_size=48
-allow_system_fallback=true
-force_autohinter=false
-modulate_color_glyphs=false
-hinting=1
-subpixel_positioning=4
-keep_rounding_remainders=true
-oversampling=0.0
-Fallbacks=null
-fallbacks=[]
-Compress=null
-compress=true
-preload=[]
-language_support={}
-script_support={}
-opentype_features={}
+[remap]
+
+importer="font_data_dynamic"
+type="FontFile"
+uid="uid://m5ceou0rk6j6"
+path="res://.godot/imported/PlayfairDisplay-VariableFont_wght.ttf-fbc765a7962e1c71b0eb2c53d6eb2a10.fontdata"
+
+[deps]
+
+source_file="res://assets/fonts/PlayfairDisplay-VariableFont_wght.ttf"
+dest_files=["res://.godot/imported/PlayfairDisplay-VariableFont_wght.ttf-fbc765a7962e1c71b0eb2c53d6eb2a10.fontdata"]
+
+[params]
+
+Rendering=null
+antialiasing=1
+generate_mipmaps=false
+disable_embedded_bitmaps=true
+multichannel_signed_distance_field=false
+msdf_pixel_range=8
+msdf_size=48
+allow_system_fallback=true
+force_autohinter=false
+modulate_color_glyphs=false
+hinting=1
+subpixel_positioning=4
+keep_rounding_remainders=true
+oversampling=0.0
+Fallbacks=null
+fallbacks=[]
+Compress=null
+compress=true
+preload=[]
+language_support={}
+script_support={}
+opentype_features={}
diff --git a/game/assets/images/pp_start_bg.png.import b/game/assets/images/pp_start_bg.png.import
index 64da61e..17419c6 100644
--- a/game/assets/images/pp_start_bg.png.import
+++ b/game/assets/images/pp_start_bg.png.import
@@ -1,40 +1,40 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://dhuosr0p605gj"
-path="res://.godot/imported/pp_start_bg.png-8fb0f850edd45e79935f992c58fa8ca2.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://assets/images/pp_start_bg.png"
-dest_files=["res://.godot/imported/pp_start_bg.png-8fb0f850edd45e79935f992c58fa8ca2.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
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dhuosr0p605gj"
+path="res://.godot/imported/pp_start_bg.png-8fb0f850edd45e79935f992c58fa8ca2.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/images/pp_start_bg.png"
+dest_files=["res://.godot/imported/pp_start_bg.png-8fb0f850edd45e79935f992c58fa8ca2.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/assets/items/iWeapon.gd b/game/assets/items/iWeapon.gd
index ddd5e7b..aecdb29 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/items/iWeapon.gd.uid b/game/assets/items/iWeapon.gd.uid
index 47661ee..88bff25 100644
--- a/game/assets/items/iWeapon.gd.uid
+++ b/game/assets/items/iWeapon.gd.uid
@@ -1 +1 @@
-uid://bttaq8w3plgqh
+uid://bttaq8w3plgqh
diff --git a/game/assets/models/human.blend.import b/game/assets/models/human.blend.import
index d05a2b2..070e247 100644
--- a/game/assets/models/human.blend.import
+++ b/game/assets/models/human.blend.import
@@ -1,59 +1,59 @@
-[remap]
-
-importer="scene"
-importer_version=1
-type="PackedScene"
-uid="uid://bb6hj6l23043x"
-path="res://.godot/imported/human.blend-738fbf7b85a13f54d00c9db65cf59296.scn"
-
-[deps]
-
-source_file="res://assets/models/human.blend"
-dest_files=["res://.godot/imported/human.blend-738fbf7b85a13f54d00c9db65cf59296.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={}
-blender/nodes/visible=0
-blender/nodes/active_collection_only=false
-blender/nodes/punctual_lights=true
-blender/nodes/cameras=true
-blender/nodes/custom_properties=true
-blender/nodes/modifiers=1
-blender/meshes/colors=false
-blender/meshes/uvs=true
-blender/meshes/normals=true
-blender/meshes/export_geometry_nodes_instances=false
-blender/meshes/tangents=true
-blender/meshes/skins=2
-blender/meshes/export_bones_deforming_mesh_only=false
-blender/materials/unpack_enabled=true
-blender/materials/export_materials=1
-blender/animation/limit_playback=true
-blender/animation/always_sample=true
-blender/animation/group_tracks=true
-gltf/naming_version=2
+[remap]
+
+importer="scene"
+importer_version=1
+type="PackedScene"
+uid="uid://bb6hj6l23043x"
+path="res://.godot/imported/human.blend-738fbf7b85a13f54d00c9db65cf59296.scn"
+
+[deps]
+
+source_file="res://assets/models/human.blend"
+dest_files=["res://.godot/imported/human.blend-738fbf7b85a13f54d00c9db65cf59296.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={}
+blender/nodes/visible=0
+blender/nodes/active_collection_only=false
+blender/nodes/punctual_lights=true
+blender/nodes/cameras=true
+blender/nodes/custom_properties=true
+blender/nodes/modifiers=1
+blender/meshes/colors=false
+blender/meshes/uvs=true
+blender/meshes/normals=true
+blender/meshes/export_geometry_nodes_instances=false
+blender/meshes/tangents=true
+blender/meshes/skins=2
+blender/meshes/export_bones_deforming_mesh_only=false
+blender/materials/unpack_enabled=true
+blender/materials/export_materials=1
+blender/animation/limit_playback=true
+blender/animation/always_sample=true
+blender/animation/group_tracks=true
+gltf/naming_version=2
diff --git a/game/assets/test.tscn b/game/assets/test.tscn
index 672aa1a..0edf9e8 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/audio/bus_layout.tres b/game/audio/bus_layout.tres
index 03b66b7..7481000 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/export_presets.cfg b/game/export_presets.cfg
index 0ea47ff..ab578a2 100644
--- a/game/export_presets.cfg
+++ b/game/export_presets.cfg
@@ -1,70 +1,70 @@
-[preset.0]
-
-name="Windows Desktop"
-platform="Windows Desktop"
-runnable=true
-advanced_options=false
-dedicated_server=false
-custom_features=""
-export_filter="all_resources"
-include_filter=""
-exclude_filter=""
-export_path="bin/test.exe"
-patches=PackedStringArray()
-encryption_include_filters=""
-encryption_exclude_filters=""
-seed=0
-encrypt_pck=false
-encrypt_directory=false
-script_export_mode=2
-
-[preset.0.options]
-
-custom_template/debug=""
-custom_template/release=""
-debug/export_console_wrapper=1
-binary_format/embed_pck=false
-texture_format/s3tc_bptc=true
-texture_format/etc2_astc=false
-shader_baker/enabled=false
-binary_format/architecture="x86_64"
-codesign/enable=false
-codesign/timestamp=true
-codesign/timestamp_server_url=""
-codesign/digest_algorithm=1
-codesign/description=""
-codesign/custom_options=PackedStringArray()
-application/modify_resources=true
-application/icon=""
-application/console_wrapper_icon=""
-application/icon_interpolation=4
-application/file_version=""
-application/product_version=""
-application/company_name=""
-application/product_name=""
-application/file_description=""
-application/copyright=""
-application/trademarks=""
-application/export_angle=0
-application/export_d3d12=0
-application/d3d12_agility_sdk_multiarch=true
-ssh_remote_deploy/enabled=false
-ssh_remote_deploy/host="user@host_ip"
-ssh_remote_deploy/port="22"
-ssh_remote_deploy/extra_args_ssh=""
-ssh_remote_deploy/extra_args_scp=""
-ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
-$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
-$trigger = New-ScheduledTaskTrigger -Once -At 00:00
-$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
-$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
-Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
-Start-ScheduledTask -TaskName godot_remote_debug
-while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
-Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
-ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
-Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
-Remove-Item -Recurse -Force '{temp_dir}'"
-dotnet/include_scripts_content=false
-dotnet/include_debug_symbols=true
-dotnet/embed_build_outputs=false
+[preset.0]
+
+name="Windows Desktop"
+platform="Windows Desktop"
+runnable=true
+advanced_options=false
+dedicated_server=false
+custom_features=""
+export_filter="all_resources"
+include_filter=""
+exclude_filter=""
+export_path="bin/test.exe"
+patches=PackedStringArray()
+encryption_include_filters=""
+encryption_exclude_filters=""
+seed=0
+encrypt_pck=false
+encrypt_directory=false
+script_export_mode=2
+
+[preset.0.options]
+
+custom_template/debug=""
+custom_template/release=""
+debug/export_console_wrapper=1
+binary_format/embed_pck=false
+texture_format/s3tc_bptc=true
+texture_format/etc2_astc=false
+shader_baker/enabled=false
+binary_format/architecture="x86_64"
+codesign/enable=false
+codesign/timestamp=true
+codesign/timestamp_server_url=""
+codesign/digest_algorithm=1
+codesign/description=""
+codesign/custom_options=PackedStringArray()
+application/modify_resources=true
+application/icon=""
+application/console_wrapper_icon=""
+application/icon_interpolation=4
+application/file_version=""
+application/product_version=""
+application/company_name=""
+application/product_name=""
+application/file_description=""
+application/copyright=""
+application/trademarks=""
+application/export_angle=0
+application/export_d3d12=0
+application/d3d12_agility_sdk_multiarch=true
+ssh_remote_deploy/enabled=false
+ssh_remote_deploy/host="user@host_ip"
+ssh_remote_deploy/port="22"
+ssh_remote_deploy/extra_args_ssh=""
+ssh_remote_deploy/extra_args_scp=""
+ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
+$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
+$trigger = New-ScheduledTaskTrigger -Once -At 00:00
+$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
+$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
+Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
+Start-ScheduledTask -TaskName godot_remote_debug
+while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
+Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
+ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
+Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
+Remove-Item -Recurse -Force '{temp_dir}'"
+dotnet/include_scripts_content=false
+dotnet/include_debug_symbols=true
+dotnet/embed_build_outputs=false
diff --git a/game/icon.svg b/game/icon.svg
index c6bbb7d..1efc4be 100644
--- a/game/icon.svg
+++ b/game/icon.svg
@@ -1 +1 @@
-
+
diff --git a/game/icon.svg.import b/game/icon.svg.import
index a53bccb..41c7dda 100644
--- a/game/icon.svg.import
+++ b/game/icon.svg.import
@@ -1,43 +1,43 @@
-[remap]
-
-importer="texture"
-type="CompressedTexture2D"
-uid="uid://f2g3tvryiodc"
-path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
-metadata={
-"vram_texture": false
-}
-
-[deps]
-
-source_file="res://icon.svg"
-dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.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
-svg/scale=1.0
-editor/scale_with_editor_scale=false
-editor/convert_colors_with_editor_theme=false
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://f2g3tvryiodc"
+path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://icon.svg"
+dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.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
+svg/scale=1.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/game/project.godot b/game/project.godot
index 539e581..e2cdff6 100644
--- a/game/project.godot
+++ b/game/project.godot
@@ -1,76 +1,76 @@
-; Engine configuration file.
-; It's best edited using the editor UI and not directly,
-; since the parameters that go here are not all obvious.
-;
-; Format:
-; [section] ; section goes between []
-; param=value ; assign values to parameters
-
-config_version=5
-
-[application]
-
-config/name="Promiscuity"
-run/main_scene="uid://b4k81taauef4q"
-config/features=PackedStringArray("4.5", "Forward Plus")
-config/icon="res://icon.svg"
-
-[audio]
-
-bus_layout="res://audio/bus_layout.tres"
-
-[autoload]
-
-MenuMusic="*res://scenes/UI/menu_music.tscn"
-MenuSfx="*res://scenes/UI/menu_sfx.tscn"
-AuthState="*res://scenes/UI/auth_state.gd"
-CharacterService="*res://scenes/UI/character_service.gd"
-
-[dotnet]
-
-project/assembly_name="Promiscuity"
-
-[input]
-
-ui_left={
-"deadzone": 0.5,
-"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
-, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
-, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":-1.0,"script":null)
-, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
-]
-}
-ui_right={
-"deadzone": 0.5,
-"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
-, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
-, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":1.0,"script":null)
-, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
-]
-}
-ui_up={
-"deadzone": 0.5,
-"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
-, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
-, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":-1.0,"script":null)
-, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
-]
-}
-ui_down={
-"deadzone": 0.5,
-"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194322,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
-, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
-, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":1.0,"script":null)
-, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
-]
-}
-player_light={
-"deadzone": 0.2,
-"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
-]
-}
-player_phone={
-"deadzone": 0.2,
-"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
-]
-}
+; Engine configuration file.
+; It's best edited using the editor UI and not directly,
+; since the parameters that go here are not all obvious.
+;
+; Format:
+; [section] ; section goes between []
+; param=value ; assign values to parameters
+
+config_version=5
+
+[application]
+
+config/name="Promiscuity"
+run/main_scene="uid://b4k81taauef4q"
+config/features=PackedStringArray("4.5", "Forward Plus")
+config/icon="res://icon.svg"
+
+[audio]
+
+bus_layout="res://audio/bus_layout.tres"
+
+[autoload]
+
+MenuMusic="*res://scenes/UI/menu_music.tscn"
+MenuSfx="*res://scenes/UI/menu_sfx.tscn"
+AuthState="*res://scenes/UI/auth_state.gd"
+CharacterService="*res://scenes/UI/character_service.gd"
+
+[dotnet]
+
+project/assembly_name="Promiscuity"
+
+[input]
+
+ui_left={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":-1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
+]
+}
+ui_right={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":0,"axis_value":1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
+]
+}
+ui_up={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":-1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
+]
+}
+ui_down={
+"deadzone": 0.5,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194322,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
+, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":0,"axis":1,"axis_value":1.0,"script":null)
+, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
+]
+}
+player_light={
+"deadzone": 0.2,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
+player_phone={
+"deadzone": 0.2,
+"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
+]
+}
diff --git a/game/scenes/Characters/repo_bot.gd b/game/scenes/Characters/repo_bot.gd
index 7c77eb7..7d0dd39 100644
--- a/game/scenes/Characters/repo_bot.gd
+++ b/game/scenes/Characters/repo_bot.gd
@@ -1,156 +1,156 @@
-extends Node3D
-
-@export var left_pupil_path: NodePath = NodePath("Body/HeadPivot/EyeLeft/Pupil")
-@export var right_pupil_path: NodePath = NodePath("Body/HeadPivot/EyeRight/Pupil")
-@export var camera_path: NodePath
-@export var look_origin_path: NodePath = NodePath("Body/HeadPivot")
-@export var look_reference_path: NodePath = NodePath("Body")
-@export var lock_vertical: bool = true
-@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
-
-var _left_pupil: Node3D
-var _right_pupil: Node3D
-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 _vertical_hold_timer: float = 0.0
-var _look_reference: Node3D
-
-
-func _ready() -> void:
- _left_pupil = get_node_or_null(left_pupil_path) as Node3D
- _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()
- _look_origin = get_node_or_null(look_origin_path) as Node3D
- _look_reference = get_node_or_null(look_reference_path) as Node3D
- _head = get_node_or_null(head_path) as Node3D
- if _head:
- _head_base_rot = _head.rotation
-
-
-func _physics_process(_delta: float) -> void:
- _update_pupils()
-
-
-func _process(_delta: float) -> void:
- _update_pupils()
-
-
-func _update_pupils() -> void:
- if _camera == null or not _camera.is_inside_tree():
- _camera = _resolve_camera()
- if _camera == null:
- return
- var origin := _look_origin
- if origin == null:
- origin = self
- var target := _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)
- if _head:
- _update_head(dir_world)
-
-
-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())
+extends Node3D
+
+@export var left_pupil_path: NodePath = NodePath("Body/HeadPivot/EyeLeft/Pupil")
+@export var right_pupil_path: NodePath = NodePath("Body/HeadPivot/EyeRight/Pupil")
+@export var camera_path: NodePath
+@export var look_origin_path: NodePath = NodePath("Body/HeadPivot")
+@export var look_reference_path: NodePath = NodePath("Body")
+@export var lock_vertical: bool = true
+@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
+
+var _left_pupil: Node3D
+var _right_pupil: Node3D
+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 _vertical_hold_timer: float = 0.0
+var _look_reference: Node3D
+
+
+func _ready() -> void:
+ _left_pupil = get_node_or_null(left_pupil_path) as Node3D
+ _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()
+ _look_origin = get_node_or_null(look_origin_path) as Node3D
+ _look_reference = get_node_or_null(look_reference_path) as Node3D
+ _head = get_node_or_null(head_path) as Node3D
+ if _head:
+ _head_base_rot = _head.rotation
+
+
+func _physics_process(_delta: float) -> void:
+ _update_pupils()
+
+
+func _process(_delta: float) -> void:
+ _update_pupils()
+
+
+func _update_pupils() -> void:
+ if _camera == null or not _camera.is_inside_tree():
+ _camera = _resolve_camera()
+ if _camera == null:
+ return
+ var origin := _look_origin
+ if origin == null:
+ origin = self
+ var target := _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)
+ if _head:
+ _update_head(dir_world)
+
+
+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())
diff --git a/game/scenes/Characters/repo_bot.gd.uid b/game/scenes/Characters/repo_bot.gd.uid
index a21e00c..1b9b1a2 100644
--- a/game/scenes/Characters/repo_bot.gd.uid
+++ b/game/scenes/Characters/repo_bot.gd.uid
@@ -1 +1 @@
-uid://bs3eqqujhetsm
+uid://bs3eqqujhetsm
diff --git a/game/scenes/Characters/repo_bot.tscn b/game/scenes/Characters/repo_bot.tscn
index edd725d..a0e09a1 100644
--- a/game/scenes/Characters/repo_bot.tscn
+++ b/game/scenes/Characters/repo_bot.tscn
@@ -1,111 +1,111 @@
-[gd_scene load_steps=14 format=3]
-
-[ext_resource type="Script" path="res://scenes/Characters/repo_bot.gd" id="1_repo_bot"]
-
-[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
-
-[node name="RepoBot" type="Node3D"]
-script = ExtResource("1_repo_bot")
-
-[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.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")
+[gd_scene load_steps=14 format=3]
+
+[ext_resource type="Script" path="res://scenes/Characters/repo_bot.gd" id="1_repo_bot"]
+
+[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
+
+[node name="RepoBot" type="Node3D"]
+script = ExtResource("1_repo_bot")
+
+[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.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/Levels/level.gd b/game/scenes/Levels/level.gd
index 369d438..a1f4b65 100644
--- a/game/scenes/Levels/level.gd
+++ b/game/scenes/Levels/level.gd
@@ -1,22 +1,22 @@
-extends Node3D
-
-@export var day_length := 120.0 # seconds for full rotation
-@export var start_light_angle := -90.0
-var end_light_angle = start_light_angle + 360.0
-var start_radians = start_light_angle * PI / 180
-var time := 0.0
-
-@onready var sun := $DirectionalLight3D
-
-func _process(delta):
- time = fmod((time + delta), day_length)
- var t = time / day_length
-
- # Rotate sun around X axis
- var angle = lerp(start_light_angle, end_light_angle, t) # sunrise → sunset → night → sunrise
- sun.rotation_degrees.x = angle
-
- # Adjust intensity
- var curSin = -sin((t * TAU) + start_radians)
- var energy = clamp((curSin * 1.0) + 0.2, 0.0, 1.2)
- sun.light_energy = energy
+extends Node3D
+
+@export var day_length := 120.0 # seconds for full rotation
+@export var start_light_angle := -90.0
+var end_light_angle = start_light_angle + 360.0
+var start_radians = start_light_angle * PI / 180
+var time := 0.0
+
+@onready var sun := $DirectionalLight3D
+
+func _process(delta):
+ time = fmod((time + delta), day_length)
+ var t = time / day_length
+
+ # Rotate sun around X axis
+ var angle = lerp(start_light_angle, end_light_angle, t) # sunrise → sunset → night → sunrise
+ sun.rotation_degrees.x = angle
+
+ # Adjust intensity
+ var curSin = -sin((t * TAU) + start_radians)
+ var energy = clamp((curSin * 1.0) + 0.2, 0.0, 1.2)
+ sun.light_energy = energy
diff --git a/game/scenes/Levels/level.gd.uid b/game/scenes/Levels/level.gd.uid
index 2ac5d25..9be8903 100644
--- a/game/scenes/Levels/level.gd.uid
+++ b/game/scenes/Levels/level.gd.uid
@@ -1 +1 @@
-uid://brgmxhhhtakja
+uid://brgmxhhhtakja
diff --git a/game/scenes/Levels/level.tscn b/game/scenes/Levels/level.tscn
index 749eba8..e9f7d93 100644
--- a/game/scenes/Levels/level.tscn
+++ b/game/scenes/Levels/level.tscn
@@ -1,170 +1,170 @@
-[gd_scene load_steps=17 format=3 uid="uid://dchj6g2i8ebph"]
-
-[ext_resource type="Script" uid="uid://brgmxhhhtakja" path="res://scenes/Levels/level.gd" id="1_a4mo8"]
-[ext_resource type="PackedScene" uid="uid://bb6hj6l23043x" path="res://assets/models/human.blend" id="1_eg4yq"]
-[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="Script" uid="uid://b7fopt7sx74g8" path="res://scenes/Levels/menu.gd" id="3_tc7dm"]
-[ext_resource type="PackedScene" path="res://scenes/Characters/repo_bot.tscn" id="4_repo"]
-
-[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_2q6dc"]
-bounce = 0.5
-
-[sub_resource type="SphereShape3D" id="SphereShape3D_2q6dc"]
-
-[sub_resource type="SphereMesh" id="SphereMesh_w7c3h"]
-
-[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_w8frs"]
-bounce = 0.5
-
-[sub_resource type="SphereShape3D" id="SphereShape3D_mx8sn"]
-
-[sub_resource type="BoxShape3D" id="BoxShape3D_2q6dc"]
-size = Vector3(1080, 2, 1080)
-
-[sub_resource type="BoxMesh" id="BoxMesh_w7c3h"]
-size = Vector3(1080, 2, 1080)
-
-[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_fi66n"]
-
-[sub_resource type="Sky" id="Sky_a4mo8"]
-sky_material = SubResource("ProceduralSkyMaterial_fi66n")
-
-[sub_resource type="Environment" id="Environment_a4mo8"]
-background_mode = 2
-sky = SubResource("Sky_a4mo8")
-ambient_light_source = 3
-
-[node name="Node3D" type="Node3D"]
-script = ExtResource("1_a4mo8")
-
-[node name="human" parent="." instance=ExtResource("1_eg4yq")]
-
-[node name="RepoBot" parent="." instance=ExtResource("4_repo")]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.9426608, 0, -4.4451966)
-
-[node name="Thing" type="RigidBody3D" parent="."]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -3.7986288)
-physics_material_override = SubResource("PhysicsMaterial_2q6dc")
-gravity_scale = 0.0
-contact_monitor = true
-max_contacts_reported = 5
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="Thing"]
-shape = SubResource("SphereShape3D_2q6dc")
-debug_color = Color(0.29772994, 0.6216631, 0.28140613, 0.41960785)
-
-[node name="MeshInstance3D" type="MeshInstance3D" parent="Thing"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
-mesh = SubResource("SphereMesh_w7c3h")
-
-[node name="Player" type="RigidBody3D" parent="."]
-physics_material_override = SubResource("PhysicsMaterial_w8frs")
-script = ExtResource("1_muv8p")
-camera_path = NodePath("Camera3D")
-phone_path = NodePath("../PhoneUI")
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
-shape = SubResource("SphereShape3D_mx8sn")
-
-[node name="Camera3D" type="Camera3D" parent="Player"]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.31670225, 0)
-current = true
-
-[node name="SpotLight3D" type="SpotLight3D" parent="Player"]
-
-[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_2q6dc")
-
-[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
-mesh = SubResource("BoxMesh_w7c3h")
-
-[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
-transform = Transform3D(1, 0, 0, 0, 0.5, 0.8660253, 0, -0.8660253, 0.5, 0, 34, 0)
-shadow_enabled = true
-
-[node name="Starter Blocks" type="Node3D" parent="."]
-
-[node name="Block" parent="Starter Blocks" instance=ExtResource("2_tc7dm")]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.298158, -7.0724635)
-
-[node name="Block2" parent="Starter Blocks" instance=ExtResource("2_tc7dm")]
-transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.63255787, 2.596316, -6.980046)
-
-[node name="Menu" type="CanvasLayer" parent="."]
-process_mode = 3
-visible = false
-script = ExtResource("3_tc7dm")
-
-[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="Control" type="Control" parent="Menu"]
-layout_mode = 3
-anchors_preset = 15
-anchor_right = 1.0
-anchor_bottom = 1.0
-grow_horizontal = 2
-grow_vertical = 2
-size_flags_horizontal = 4
-size_flags_vertical = 4
-
-[node name="VBoxContainer" type="VBoxContainer" parent="Menu/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 = -39.5
-offset_top = -33.0
-offset_right = 39.5
-offset_bottom = 33.0
-grow_horizontal = 2
-grow_vertical = 2
-
-[node name="ContinueButton" type="Button" parent="Menu/Control/VBoxContainer"]
-layout_mode = 2
-text = "Continue"
-
-[node name="MainMenuButton" type="Button" parent="Menu/Control/VBoxContainer"]
-layout_mode = 2
-text = "Main Menu"
-
-[node name="QuitButton" type="Button" parent="Menu/Control/VBoxContainer"]
-layout_mode = 2
-text = "Quit"
-
-[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
-environment = SubResource("Environment_a4mo8")
-
-[connection signal="pressed" from="Menu/Control/VBoxContainer/ContinueButton" to="Menu" method="_on_continue_button_pressed"]
-[connection signal="pressed" from="Menu/Control/VBoxContainer/MainMenuButton" to="Menu" method="_on_main_menu_button_pressed"]
-[connection signal="pressed" from="Menu/Control/VBoxContainer/QuitButton" to="Menu" method="_on_quit_button_pressed"]
+[gd_scene load_steps=17 format=3 uid="uid://dchj6g2i8ebph"]
+
+[ext_resource type="Script" uid="uid://brgmxhhhtakja" path="res://scenes/Levels/level.gd" id="1_a4mo8"]
+[ext_resource type="PackedScene" uid="uid://bb6hj6l23043x" path="res://assets/models/human.blend" id="1_eg4yq"]
+[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="Script" uid="uid://b7fopt7sx74g8" path="res://scenes/Levels/menu.gd" id="3_tc7dm"]
+[ext_resource type="PackedScene" path="res://scenes/Characters/repo_bot.tscn" id="4_repo"]
+
+[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_2q6dc"]
+bounce = 0.5
+
+[sub_resource type="SphereShape3D" id="SphereShape3D_2q6dc"]
+
+[sub_resource type="SphereMesh" id="SphereMesh_w7c3h"]
+
+[sub_resource type="PhysicsMaterial" id="PhysicsMaterial_w8frs"]
+bounce = 0.5
+
+[sub_resource type="SphereShape3D" id="SphereShape3D_mx8sn"]
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_2q6dc"]
+size = Vector3(1080, 2, 1080)
+
+[sub_resource type="BoxMesh" id="BoxMesh_w7c3h"]
+size = Vector3(1080, 2, 1080)
+
+[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_fi66n"]
+
+[sub_resource type="Sky" id="Sky_a4mo8"]
+sky_material = SubResource("ProceduralSkyMaterial_fi66n")
+
+[sub_resource type="Environment" id="Environment_a4mo8"]
+background_mode = 2
+sky = SubResource("Sky_a4mo8")
+ambient_light_source = 3
+
+[node name="Node3D" type="Node3D"]
+script = ExtResource("1_a4mo8")
+
+[node name="human" parent="." instance=ExtResource("1_eg4yq")]
+
+[node name="RepoBot" parent="." instance=ExtResource("4_repo")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.9426608, 0, -4.4451966)
+
+[node name="Thing" type="RigidBody3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -3.7986288)
+physics_material_override = SubResource("PhysicsMaterial_2q6dc")
+gravity_scale = 0.0
+contact_monitor = true
+max_contacts_reported = 5
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Thing"]
+shape = SubResource("SphereShape3D_2q6dc")
+debug_color = Color(0.29772994, 0.6216631, 0.28140613, 0.41960785)
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="Thing"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
+mesh = SubResource("SphereMesh_w7c3h")
+
+[node name="Player" type="RigidBody3D" parent="."]
+physics_material_override = SubResource("PhysicsMaterial_w8frs")
+script = ExtResource("1_muv8p")
+camera_path = NodePath("Camera3D")
+phone_path = NodePath("../PhoneUI")
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
+shape = SubResource("SphereShape3D_mx8sn")
+
+[node name="Camera3D" type="Camera3D" parent="Player"]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.31670225, 0)
+current = true
+
+[node name="SpotLight3D" type="SpotLight3D" parent="Player"]
+
+[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_2q6dc")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
+mesh = SubResource("BoxMesh_w7c3h")
+
+[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
+transform = Transform3D(1, 0, 0, 0, 0.5, 0.8660253, 0, -0.8660253, 0.5, 0, 34, 0)
+shadow_enabled = true
+
+[node name="Starter Blocks" type="Node3D" parent="."]
+
+[node name="Block" parent="Starter Blocks" instance=ExtResource("2_tc7dm")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.298158, -7.0724635)
+
+[node name="Block2" parent="Starter Blocks" instance=ExtResource("2_tc7dm")]
+transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.63255787, 2.596316, -6.980046)
+
+[node name="Menu" type="CanvasLayer" parent="."]
+process_mode = 3
+visible = false
+script = ExtResource("3_tc7dm")
+
+[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="Control" type="Control" parent="Menu"]
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+
+[node name="VBoxContainer" type="VBoxContainer" parent="Menu/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 = -39.5
+offset_top = -33.0
+offset_right = 39.5
+offset_bottom = 33.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="ContinueButton" type="Button" parent="Menu/Control/VBoxContainer"]
+layout_mode = 2
+text = "Continue"
+
+[node name="MainMenuButton" type="Button" parent="Menu/Control/VBoxContainer"]
+layout_mode = 2
+text = "Main Menu"
+
+[node name="QuitButton" type="Button" parent="Menu/Control/VBoxContainer"]
+layout_mode = 2
+text = "Quit"
+
+[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
+environment = SubResource("Environment_a4mo8")
+
+[connection signal="pressed" from="Menu/Control/VBoxContainer/ContinueButton" to="Menu" method="_on_continue_button_pressed"]
+[connection signal="pressed" from="Menu/Control/VBoxContainer/MainMenuButton" to="Menu" method="_on_main_menu_button_pressed"]
+[connection signal="pressed" from="Menu/Control/VBoxContainer/QuitButton" to="Menu" method="_on_quit_button_pressed"]
diff --git a/game/scenes/Levels/menu.gd b/game/scenes/Levels/menu.gd
index 38dd9de..1d7e852 100644
--- a/game/scenes/Levels/menu.gd
+++ b/game/scenes/Levels/menu.gd
@@ -1,51 +1,51 @@
-extends CanvasLayer
-
-const START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn"
-
-@onready var pause_menu = $Control
-
-func _ready() -> void:
- _register_focus_sounds()
-
-func _input(event):
- if event.is_action_pressed("ui_cancel"):
- if get_tree().paused:
- resume_game()
- else:
- pause_game()
-
-func pause_game():
- get_tree().paused = true
- visible = true
-
-func resume_game():
- get_tree().paused = false
- visible = false
-
-func _on_quit_button_pressed():
- get_tree().quit()
-
-func _on_continue_button_pressed():
- resume_game()
-
-func _on_main_menu_button_pressed():
- resume_game()
- get_tree().change_scene_to_file(START_SCREEN_SCENE)
-
-func _register_focus_sounds() -> void:
- if pause_menu == null:
- return
- var vbox := pause_menu.get_node_or_null("VBoxContainer")
- if vbox == null:
- return
- for child in vbox.get_children():
- if child is BaseButton:
- var button: BaseButton = child
- if not button.is_connected("focus_entered", Callable(self, "_on_menu_item_focus")):
- button.focus_entered.connect(_on_menu_item_focus)
- if not button.is_connected("mouse_entered", Callable(self, "_on_menu_item_focus")):
- button.mouse_entered.connect(_on_menu_item_focus)
-
-func _on_menu_item_focus() -> void:
- if MenuSfx:
- MenuSfx.play_hover()
+extends CanvasLayer
+
+const START_SCREEN_SCENE := "res://scenes/UI/start_screen.tscn"
+
+@onready var pause_menu = $Control
+
+func _ready() -> void:
+ _register_focus_sounds()
+
+func _input(event):
+ if event.is_action_pressed("ui_cancel"):
+ if get_tree().paused:
+ resume_game()
+ else:
+ pause_game()
+
+func pause_game():
+ get_tree().paused = true
+ visible = true
+
+func resume_game():
+ get_tree().paused = false
+ visible = false
+
+func _on_quit_button_pressed():
+ get_tree().quit()
+
+func _on_continue_button_pressed():
+ resume_game()
+
+func _on_main_menu_button_pressed():
+ resume_game()
+ get_tree().change_scene_to_file(START_SCREEN_SCENE)
+
+func _register_focus_sounds() -> void:
+ if pause_menu == null:
+ return
+ var vbox := pause_menu.get_node_or_null("VBoxContainer")
+ if vbox == null:
+ return
+ for child in vbox.get_children():
+ if child is BaseButton:
+ var button: BaseButton = child
+ if not button.is_connected("focus_entered", Callable(self, "_on_menu_item_focus")):
+ button.focus_entered.connect(_on_menu_item_focus)
+ if not button.is_connected("mouse_entered", Callable(self, "_on_menu_item_focus")):
+ button.mouse_entered.connect(_on_menu_item_focus)
+
+func _on_menu_item_focus() -> void:
+ if MenuSfx:
+ MenuSfx.play_hover()
diff --git a/game/scenes/Levels/menu.gd.uid b/game/scenes/Levels/menu.gd.uid
index eb141f2..651ba5d 100644
--- a/game/scenes/Levels/menu.gd.uid
+++ b/game/scenes/Levels/menu.gd.uid
@@ -1 +1 @@
-uid://b7fopt7sx74g8
+uid://b7fopt7sx74g8
diff --git a/game/scenes/UI/Settings.gd b/game/scenes/UI/Settings.gd
index 2b4d84b..89d6b15 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.gd.uid b/game/scenes/UI/Settings.gd.uid
index c51c07f..3a01061 100644
--- a/game/scenes/UI/Settings.gd.uid
+++ b/game/scenes/UI/Settings.gd.uid
@@ -1 +1 @@
-uid://h1slqbemgwvr
+uid://h1slqbemgwvr
diff --git a/game/scenes/UI/Settings.tscn b/game/scenes/UI/Settings.tscn
index f325614..01f165b 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 6f18171..1f0e03f 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/auth_state.gd.uid b/game/scenes/UI/auth_state.gd.uid
index de9087c..4321f56 100644
--- a/game/scenes/UI/auth_state.gd.uid
+++ b/game/scenes/UI/auth_state.gd.uid
@@ -1 +1 @@
-uid://ccloj2rh4dche
+uid://ccloj2rh4dche
diff --git a/game/scenes/UI/character_screen.gd b/game/scenes/UI/character_screen.gd
index 8574340..07f2864 100644
--- a/game/scenes/UI/character_screen.gd
+++ b/game/scenes/UI/character_screen.gd
@@ -1,129 +1,129 @@
-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)
- _character_list.remove_item(index)
- _status_label.text = "Character deleted."
-
-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")
+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)
+ _character_list.remove_item(index)
+ _status_label.text = "Character deleted."
+
+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.gd.uid b/game/scenes/UI/character_screen.gd.uid
index 7d464f6..5110903 100644
--- a/game/scenes/UI/character_screen.gd.uid
+++ b/game/scenes/UI/character_screen.gd.uid
@@ -1 +1 @@
-uid://c2y7ftq2k3v4x
+uid://c2y7ftq2k3v4x
diff --git a/game/scenes/UI/character_screen.tscn b/game/scenes/UI/character_screen.tscn
index 2d959b6..1131bd7 100644
--- a/game/scenes/UI/character_screen.tscn
+++ b/game/scenes/UI/character_screen.tscn
@@ -1,131 +1,131 @@
-[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
-theme_override_constants/separation = 10
-
-[node name="RefreshButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ActionHBox"]
-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
-
-[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/AddHBox/AddButton" to="." method="_on_add_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"]
+[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
+theme_override_constants/separation = 10
+
+[node name="RefreshButton" type="Button" parent="MarginContainer/ContentCenter/ContentVBox/ActionHBox"]
+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
+
+[connection signal="pressed" from="MarginContainer/ContentCenter/ContentVBox/AddHBox/AddButton" to="." method="_on_add_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"]
diff --git a/game/scenes/UI/character_service.gd b/game/scenes/UI/character_service.gd
index 38d8c8d..56a05e5 100644
--- a/game/scenes/UI/character_service.gd
+++ b/game/scenes/UI/character_service.gd
@@ -1,58 +1,58 @@
-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)
-
-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
- }
+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)
+
+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/character_service.gd.uid b/game/scenes/UI/character_service.gd.uid
index 2146400..c079540 100644
--- a/game/scenes/UI/character_service.gd.uid
+++ b/game/scenes/UI/character_service.gd.uid
@@ -1 +1 @@
-uid://c8kchv0e77yw4
+uid://c8kchv0e77yw4
diff --git a/game/scenes/UI/login_screen.gd b/game/scenes/UI/login_screen.gd
index fa78caa..0d5f699 100644
--- a/game/scenes/UI/login_screen.gd
+++ b/game/scenes/UI/login_screen.gd
@@ -1,48 +1,48 @@
-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
-@onready var _error_label: Label = %ErrorLabel
-
-func _on_log_in_button_pressed() -> void:
- var username := _username_input.text.strip_edges()
- 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")
-
-func _show_error(message: String) -> void:
- _error_label.text = message
+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
+@onready var _error_label: Label = %ErrorLabel
+
+func _on_log_in_button_pressed() -> void:
+ var username := _username_input.text.strip_edges()
+ 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")
+
+func _show_error(message: String) -> void:
+ _error_label.text = message
diff --git a/game/scenes/UI/login_screen.gd.uid b/game/scenes/UI/login_screen.gd.uid
index dc91b5d..f62a54e 100644
--- a/game/scenes/UI/login_screen.gd.uid
+++ b/game/scenes/UI/login_screen.gd.uid
@@ -1 +1 @@
-uid://bnrhapdcfvp04
+uid://bnrhapdcfvp04
diff --git a/game/scenes/UI/login_screen.tscn b/game/scenes/UI/login_screen.tscn
index 007072d..2b7386d 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 c39db1f..0ecd9bb 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.gd.uid b/game/scenes/UI/menu_music.gd.uid
index 579cdad..d6d9601 100644
--- a/game/scenes/UI/menu_music.gd.uid
+++ b/game/scenes/UI/menu_music.gd.uid
@@ -1 +1 @@
-uid://l0cqi7dvoou3
+uid://l0cqi7dvoou3
diff --git a/game/scenes/UI/menu_music.tscn b/game/scenes/UI/menu_music.tscn
index afdf4da..cfaa936 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 ab57e14..21db5d4 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.gd.uid b/game/scenes/UI/menu_sfx.gd.uid
index d0a31e3..4b83a3d 100644
--- a/game/scenes/UI/menu_sfx.gd.uid
+++ b/game/scenes/UI/menu_sfx.gd.uid
@@ -1 +1 @@
-uid://c7ixr4hbh5ad6
+uid://c7ixr4hbh5ad6
diff --git a/game/scenes/UI/menu_sfx.tscn b/game/scenes/UI/menu_sfx.tscn
index 2c07257..b0a9521 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/UI/start_screen.gd b/game/scenes/UI/start_screen.gd
index c958d87..23a46df 100644
--- a/game/scenes/UI/start_screen.gd
+++ b/game/scenes/UI/start_screen.gd
@@ -1,66 +1,66 @@
-extends Control
-
-const AUTH_LOGOUT_URL := "https://pauth.ranaze.com/api/Auth/logout"
-
-@onready var _login_button: Button = $MarginContainer/CenterContainer/ContentVBox/VBoxContainer/LogInButton
-@onready var _logout_request: HTTPRequest = %LogoutRequest
-
-func _ready():
- _register_focus_sounds()
- _update_login_button()
-
-func _register_focus_sounds() -> void:
- var button_container := $MarginContainer/CenterContainer/ContentVBox/VBoxContainer
- for child in button_container.get_children():
- if child is BaseButton:
- var button: BaseButton = child
- if not button.is_connected("focus_entered", Callable(self, "_on_menu_item_focus")):
- button.focus_entered.connect(_on_menu_item_focus)
- if not button.is_connected("mouse_entered", Callable(self, "_on_menu_item_focus")):
- button.mouse_entered.connect(_on_menu_item_focus)
-
-func _on_start_button_pressed():
- get_tree().change_scene_to_file("uid://dchj6g2i8ebph")
-
-func _on_settings_button_pressed():
- get_tree().change_scene_to_file("uid://d3tqrm4ry4l88")
-
-func _on_quit_button_pressed():
- get_tree().quit()
-
-func _on_log_in_button_pressed():
- if AuthState.is_logged_in:
- _request_logout()
- else:
- get_tree().change_scene_to_file("res://scenes/UI/login_screen.tscn")
-
-func _on_menu_item_focus() -> void:
- if MenuSfx:
- MenuSfx.play_hover()
-
-func _request_logout() -> void:
- if AuthState.access_token.is_empty():
- AuthState.clear_session()
- _update_login_button()
- 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()
- _update_login_button()
-
-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()
- _update_login_button()
-
-func _update_login_button() -> void:
- if AuthState.is_logged_in:
- _login_button.text = "LOG OUT"
- else:
- _login_button.text = "LOG IN"
+extends Control
+
+const AUTH_LOGOUT_URL := "https://pauth.ranaze.com/api/Auth/logout"
+
+@onready var _login_button: Button = $MarginContainer/CenterContainer/ContentVBox/VBoxContainer/LogInButton
+@onready var _logout_request: HTTPRequest = %LogoutRequest
+
+func _ready():
+ _register_focus_sounds()
+ _update_login_button()
+
+func _register_focus_sounds() -> void:
+ var button_container := $MarginContainer/CenterContainer/ContentVBox/VBoxContainer
+ for child in button_container.get_children():
+ if child is BaseButton:
+ var button: BaseButton = child
+ if not button.is_connected("focus_entered", Callable(self, "_on_menu_item_focus")):
+ button.focus_entered.connect(_on_menu_item_focus)
+ if not button.is_connected("mouse_entered", Callable(self, "_on_menu_item_focus")):
+ button.mouse_entered.connect(_on_menu_item_focus)
+
+func _on_start_button_pressed():
+ get_tree().change_scene_to_file("uid://dchj6g2i8ebph")
+
+func _on_settings_button_pressed():
+ get_tree().change_scene_to_file("uid://d3tqrm4ry4l88")
+
+func _on_quit_button_pressed():
+ get_tree().quit()
+
+func _on_log_in_button_pressed():
+ if AuthState.is_logged_in:
+ _request_logout()
+ else:
+ get_tree().change_scene_to_file("res://scenes/UI/login_screen.tscn")
+
+func _on_menu_item_focus() -> void:
+ if MenuSfx:
+ MenuSfx.play_hover()
+
+func _request_logout() -> void:
+ if AuthState.access_token.is_empty():
+ AuthState.clear_session()
+ _update_login_button()
+ 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()
+ _update_login_button()
+
+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()
+ _update_login_button()
+
+func _update_login_button() -> void:
+ if AuthState.is_logged_in:
+ _login_button.text = "LOG OUT"
+ else:
+ _login_button.text = "LOG IN"
diff --git a/game/scenes/UI/start_screen.gd.uid b/game/scenes/UI/start_screen.gd.uid
index c6922e3..dde7927 100644
--- a/game/scenes/UI/start_screen.gd.uid
+++ b/game/scenes/UI/start_screen.gd.uid
@@ -1 +1 @@
-uid://cc8lskf7y74kh
+uid://cc8lskf7y74kh
diff --git a/game/scenes/UI/start_screen.tscn b/game/scenes/UI/start_screen.tscn
index 4d9c20d..8326abd 100644
--- a/game/scenes/UI/start_screen.tscn
+++ b/game/scenes/UI/start_screen.tscn
@@ -1,100 +1,100 @@
-[gd_scene load_steps=5 format=3 uid="uid://b4k81taauef4q"]
-
-[ext_resource type="Script" uid="uid://cc8lskf7y74kh" path="res://scenes/UI/start_screen.gd" id="1_o7i0r"]
-[ext_resource type="Theme" uid="uid://wpxmub0n2dr3" path="res://themes/button_theme.tres" id="1_tx5wa"]
-[ext_resource type="Texture2D" uid="uid://dhuosr0p605gj" path="res://assets/images/pp_start_bg.png" id="2_r2jwc"]
-[ext_resource type="Theme" uid="uid://tn8qndst18d6" path="res://themes/title_font_theme.tres" id="4_hm208"]
-
-[node name="StartScreen" 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_o7i0r")
-
-[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_r2jwc")
-
-[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 = 10
-theme_override_constants/margin_top = 10
-theme_override_constants/margin_right = 10
-theme_override_constants/margin_bottom = 10
-
-[node name="CenterContainer" type="CenterContainer" parent="MarginContainer"]
-layout_mode = 2
-
-[node name="ContentVBox" type="VBoxContainer" parent="MarginContainer/CenterContainer"]
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 8
-theme_override_constants/separation = 10
-
-[node name="Label" type="Label" parent="MarginContainer/CenterContainer/ContentVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-theme = ExtResource("4_hm208")
-text = "PROJECT
-PROMISCUOUS"
-horizontal_alignment = 1
-
-[node name="TitleSpacer" type="Control" parent="MarginContainer/CenterContainer/ContentVBox"]
-custom_minimum_size = Vector2(0, 40)
-layout_mode = 2
-
-[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/CenterContainer/ContentVBox"]
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 8
-theme_override_constants/separation = 6
-
-[node name="LogInButton" type="Button" parent="MarginContainer/CenterContainer/ContentVBox/VBoxContainer"]
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 4
-theme = ExtResource("1_tx5wa")
-text = "LOG IN"
-
-[node name="StartButton" type="Button" parent="MarginContainer/CenterContainer/ContentVBox/VBoxContainer"]
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 4
-theme = ExtResource("1_tx5wa")
-text = "START"
-
-[node name="SettingsButton" type="Button" parent="MarginContainer/CenterContainer/ContentVBox/VBoxContainer"]
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 4
-theme = ExtResource("1_tx5wa")
-text = "SETTINGS"
-
-[node name="QuitButton" type="Button" parent="MarginContainer/CenterContainer/ContentVBox/VBoxContainer"]
-layout_mode = 2
-size_flags_horizontal = 4
-size_flags_vertical = 4
-theme = ExtResource("1_tx5wa")
-text = "QUIT"
-
-[node name="LogoutRequest" type="HTTPRequest" parent="."]
-unique_name_in_owner = true
-
-[connection signal="pressed" from="MarginContainer/CenterContainer/ContentVBox/VBoxContainer/LogInButton" to="." method="_on_log_in_button_pressed"]
-[connection signal="pressed" from="MarginContainer/CenterContainer/ContentVBox/VBoxContainer/StartButton" to="." method="_on_start_button_pressed"]
-[connection signal="pressed" from="MarginContainer/CenterContainer/ContentVBox/VBoxContainer/SettingsButton" to="." method="_on_settings_button_pressed"]
-[connection signal="pressed" from="MarginContainer/CenterContainer/ContentVBox/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
-[connection signal="request_completed" from="LogoutRequest" to="." method="_on_logout_request_completed"]
+[gd_scene load_steps=5 format=3 uid="uid://b4k81taauef4q"]
+
+[ext_resource type="Script" uid="uid://cc8lskf7y74kh" path="res://scenes/UI/start_screen.gd" id="1_o7i0r"]
+[ext_resource type="Theme" uid="uid://wpxmub0n2dr3" path="res://themes/button_theme.tres" id="1_tx5wa"]
+[ext_resource type="Texture2D" uid="uid://dhuosr0p605gj" path="res://assets/images/pp_start_bg.png" id="2_r2jwc"]
+[ext_resource type="Theme" uid="uid://tn8qndst18d6" path="res://themes/title_font_theme.tres" id="4_hm208"]
+
+[node name="StartScreen" 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_o7i0r")
+
+[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_r2jwc")
+
+[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 = 10
+theme_override_constants/margin_top = 10
+theme_override_constants/margin_right = 10
+theme_override_constants/margin_bottom = 10
+
+[node name="CenterContainer" type="CenterContainer" parent="MarginContainer"]
+layout_mode = 2
+
+[node name="ContentVBox" type="VBoxContainer" parent="MarginContainer/CenterContainer"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 8
+theme_override_constants/separation = 10
+
+[node name="Label" type="Label" parent="MarginContainer/CenterContainer/ContentVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+theme = ExtResource("4_hm208")
+text = "PROJECT
+PROMISCUOUS"
+horizontal_alignment = 1
+
+[node name="TitleSpacer" type="Control" parent="MarginContainer/CenterContainer/ContentVBox"]
+custom_minimum_size = Vector2(0, 40)
+layout_mode = 2
+
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer/CenterContainer/ContentVBox"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 8
+theme_override_constants/separation = 6
+
+[node name="LogInButton" type="Button" parent="MarginContainer/CenterContainer/ContentVBox/VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme = ExtResource("1_tx5wa")
+text = "LOG IN"
+
+[node name="StartButton" type="Button" parent="MarginContainer/CenterContainer/ContentVBox/VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme = ExtResource("1_tx5wa")
+text = "START"
+
+[node name="SettingsButton" type="Button" parent="MarginContainer/CenterContainer/ContentVBox/VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme = ExtResource("1_tx5wa")
+text = "SETTINGS"
+
+[node name="QuitButton" type="Button" parent="MarginContainer/CenterContainer/ContentVBox/VBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 4
+size_flags_vertical = 4
+theme = ExtResource("1_tx5wa")
+text = "QUIT"
+
+[node name="LogoutRequest" type="HTTPRequest" parent="."]
+unique_name_in_owner = true
+
+[connection signal="pressed" from="MarginContainer/CenterContainer/ContentVBox/VBoxContainer/LogInButton" to="." method="_on_log_in_button_pressed"]
+[connection signal="pressed" from="MarginContainer/CenterContainer/ContentVBox/VBoxContainer/StartButton" to="." method="_on_start_button_pressed"]
+[connection signal="pressed" from="MarginContainer/CenterContainer/ContentVBox/VBoxContainer/SettingsButton" to="." method="_on_settings_button_pressed"]
+[connection signal="pressed" from="MarginContainer/CenterContainer/ContentVBox/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
+[connection signal="request_completed" from="LogoutRequest" to="." method="_on_logout_request_completed"]
diff --git a/game/scenes/block.tscn b/game/scenes/block.tscn
index 3e1244e..b96d3be 100644
--- a/game/scenes/block.tscn
+++ b/game/scenes/block.tscn
@@ -1,21 +1,21 @@
-[gd_scene load_steps=4 format=3 uid="uid://c5of6aaxop1hl"]
-
-[sub_resource type="BoxShape3D" id="BoxShape3D_4du60"]
-
-[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_alp5v"]
-albedo_color = Color(0.290196, 0.698039, 0.227451, 1)
-
-[sub_resource type="BoxMesh" id="BoxMesh_kryjk"]
-material = SubResource("StandardMaterial3D_alp5v")
-
-[node name="Block" type="Node3D"]
-
-[node name="RigidBody3D" type="RigidBody3D" parent="."]
-collision_layer = 3
-collision_mask = 3
-
-[node name="CollisionShape3D" type="CollisionShape3D" parent="RigidBody3D"]
-shape = SubResource("BoxShape3D_4du60")
-
-[node name="MeshInstance3D" type="MeshInstance3D" parent="RigidBody3D"]
-mesh = SubResource("BoxMesh_kryjk")
+[gd_scene load_steps=4 format=3 uid="uid://c5of6aaxop1hl"]
+
+[sub_resource type="BoxShape3D" id="BoxShape3D_4du60"]
+
+[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_alp5v"]
+albedo_color = Color(0.290196, 0.698039, 0.227451, 1)
+
+[sub_resource type="BoxMesh" id="BoxMesh_kryjk"]
+material = SubResource("StandardMaterial3D_alp5v")
+
+[node name="Block" type="Node3D"]
+
+[node name="RigidBody3D" type="RigidBody3D" parent="."]
+collision_layer = 3
+collision_mask = 3
+
+[node name="CollisionShape3D" type="CollisionShape3D" parent="RigidBody3D"]
+shape = SubResource("BoxShape3D_4du60")
+
+[node name="MeshInstance3D" type="MeshInstance3D" parent="RigidBody3D"]
+mesh = SubResource("BoxMesh_kryjk")
diff --git a/game/scenes/player.gd b/game/scenes/player.gd
index e4242c2..6036e9f 100644
--- a/game/scenes/player.gd
+++ b/game/scenes/player.gd
@@ -1,154 +1,154 @@
-extends RigidBody3D
-# Initially I used a CharacterBody3D, however, I wanted the player to bounce off
-# other objects in the environment and that would have required manual handling
-# of collisions. So that's why we're using a RigidBody3D instead.
-
-const MOVE_SPEED := 8.0
-const ACCELLERATION := 30.0
-const DECELLERATION := 40.0
-const JUMP_SPEED := 4.0
-const MAX_NUMBER_OF_JUMPS := 2
-
-const MIN_FOV := 10
-const MAX_FOV := 180
-const ZOOM_FACTOR := 1.1 # Zoom out when >1, in when < 1
-var mouse_sensitivity := 0.005
-var rotation_x := 0.0
-var rotation_y := 0.0
-var cameraMoveMode := false
-var current_number_of_jumps := 0
-var _pending_mouse_delta := Vector2.ZERO
-var _last_move_forward := Vector3(0, 0, 1)
-var _last_move_right := Vector3(1, 0, 0)
-var _camera_offset_local := Vector3.ZERO
-var _camera_yaw := 0.0
-var _camera_pitch := 0.0
-
-@export var camera_follow_speed := 10.0
-
-var jump_sound = preload("res://assets/audio/jump.ogg")
-var audio_player = AudioStreamPlayer.new()
-
-@export var camera_path: NodePath
-@onready var cam: Camera3D = get_node(camera_path) if camera_path != NodePath("") else null
-@export var phone_path: NodePath
-@onready var phone: CanvasLayer = get_node(phone_path) if phone_path != NodePath("") else null
-var phone_visible := false
-
-func _ready() -> void:
- axis_lock_angular_x = true
- axis_lock_angular_z = true
- angular_damp = 6.0
- contact_monitor = true
- max_contacts_reported = 4
- add_child(audio_player)
- audio_player.stream = jump_sound
- audio_player.volume_db = -20
- if cam:
- _camera_offset_local = cam.transform.origin
- _camera_pitch = cam.rotation.x
- _camera_yaw = global_transform.basis.get_euler().y
- cam.set_as_top_level(true)
- cam.global_position = global_position + (Basis(Vector3.UP, _camera_yaw) * _camera_offset_local)
- cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
- var move_basis := cam.global_transform.basis if cam else global_transform.basis
- var forward := move_basis.z
- var right := move_basis.x
- forward.y = 0.0
- right.y = 0.0
- if forward.length() > 0.0001:
- _last_move_forward = forward.normalized()
- if right.length() > 0.0001:
- _last_move_right = right.normalized()
-
-func _integrate_forces(state):
- if cameraMoveMode and _pending_mouse_delta != Vector2.ZERO:
- rotation_x -= _pending_mouse_delta.y * mouse_sensitivity
- rotation_y -= _pending_mouse_delta.x * mouse_sensitivity
- rotation_x = clamp(rotation_x, deg_to_rad(-90), deg_to_rad(90)) # Prevent flipping
- _camera_pitch = rotation_x
- rotation.y = rotation_y
- _pending_mouse_delta = Vector2.ZERO
-
- # Input as 2D vector
- var input2v := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
-
- if Input.is_action_just_pressed("player_phone"):
- phone_visible = !phone_visible
- if phone:
- phone.visible = phone_visible
-
- # Camera based movement
- var forward := Vector3.FORWARD * -1.0
- var right := Vector3.RIGHT
- if cam:
- forward = cam.global_transform.basis.z
- right = cam.global_transform.basis.x
- # Project onto ground plane so looking up/down doesn't kill movement.
- forward.y = 0.0
- right.y = 0.0
- if forward.length() > 0.0001:
- forward = forward.normalized()
- _last_move_forward = forward
- else:
- forward = _last_move_forward
- if right.length() > 0.0001:
- right = right.normalized()
- _last_move_right = right
- else:
- right = _last_move_right
-
- var dir := (right * input2v.x + forward * input2v.y).normalized()
- var target_v := dir * MOVE_SPEED
-
- var ax := ACCELLERATION if dir != Vector3.ZERO else DECELLERATION
- linear_velocity.x = move_toward(linear_velocity.x, target_v.x, ax * state.step)
- linear_velocity.z = move_toward(linear_velocity.z, target_v.z, ax * state.step)
-
- # Jump Logic
- var on_floor = false
- for i in state.get_contact_count():
- var normal = state.get_contact_local_normal(i)
- if normal.y > 0.5:
- on_floor = true
- break
-
- if Input.is_action_just_pressed("ui_accept") and (on_floor or current_number_of_jumps == 1):
- current_number_of_jumps = (current_number_of_jumps + 1) % 2
- linear_velocity.y = JUMP_SPEED
- audio_player.play()
-
- if cam:
- var target_yaw := global_transform.basis.get_euler().y
- _camera_yaw = lerp_angle(_camera_yaw, target_yaw, camera_follow_speed * state.step)
- var target_basis := Basis(Vector3.UP, _camera_yaw)
- var target_pos := global_position + (target_basis * _camera_offset_local)
- cam.global_position = cam.global_position.lerp(target_pos, camera_follow_speed * state.step)
- cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
-
-func _input(event):
- if event is InputEventMouseButton:
- if event.button_index == MOUSE_BUTTON_MIDDLE:
- if event.pressed:
- cameraMoveMode = true
- Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
- else:
- cameraMoveMode = false
- Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
-
- if event is InputEventMouseMotion and cameraMoveMode:
- _pending_mouse_delta += event.relative
-
- if event is InputEventMouseButton and event.pressed:
- if event.button_index == MOUSE_BUTTON_WHEEL_UP:
- zoom_camera(1.0 / ZOOM_FACTOR) # Zoom in
- elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
- zoom_camera(ZOOM_FACTOR) # Zoom out
-
- if event.is_action_pressed("player_light"):
- $SpotLight3D.visible = !$SpotLight3D.visible
-
-func zoom_camera(factor):
- var new_fov = cam.fov * factor
- cam.fov = clamp(new_fov, MIN_FOV, MAX_FOV)
-
+extends RigidBody3D
+# Initially I used a CharacterBody3D, however, I wanted the player to bounce off
+# other objects in the environment and that would have required manual handling
+# of collisions. So that's why we're using a RigidBody3D instead.
+
+const MOVE_SPEED := 8.0
+const ACCELLERATION := 30.0
+const DECELLERATION := 40.0
+const JUMP_SPEED := 4.0
+const MAX_NUMBER_OF_JUMPS := 2
+
+const MIN_FOV := 10
+const MAX_FOV := 180
+const ZOOM_FACTOR := 1.1 # Zoom out when >1, in when < 1
+var mouse_sensitivity := 0.005
+var rotation_x := 0.0
+var rotation_y := 0.0
+var cameraMoveMode := false
+var current_number_of_jumps := 0
+var _pending_mouse_delta := Vector2.ZERO
+var _last_move_forward := Vector3(0, 0, 1)
+var _last_move_right := Vector3(1, 0, 0)
+var _camera_offset_local := Vector3.ZERO
+var _camera_yaw := 0.0
+var _camera_pitch := 0.0
+
+@export var camera_follow_speed := 10.0
+
+var jump_sound = preload("res://assets/audio/jump.ogg")
+var audio_player = AudioStreamPlayer.new()
+
+@export var camera_path: NodePath
+@onready var cam: Camera3D = get_node(camera_path) if camera_path != NodePath("") else null
+@export var phone_path: NodePath
+@onready var phone: CanvasLayer = get_node(phone_path) if phone_path != NodePath("") else null
+var phone_visible := false
+
+func _ready() -> void:
+ axis_lock_angular_x = true
+ axis_lock_angular_z = true
+ angular_damp = 6.0
+ contact_monitor = true
+ max_contacts_reported = 4
+ add_child(audio_player)
+ audio_player.stream = jump_sound
+ audio_player.volume_db = -20
+ if cam:
+ _camera_offset_local = cam.transform.origin
+ _camera_pitch = cam.rotation.x
+ _camera_yaw = global_transform.basis.get_euler().y
+ cam.set_as_top_level(true)
+ cam.global_position = global_position + (Basis(Vector3.UP, _camera_yaw) * _camera_offset_local)
+ cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
+ var move_basis := cam.global_transform.basis if cam else global_transform.basis
+ var forward := move_basis.z
+ var right := move_basis.x
+ forward.y = 0.0
+ right.y = 0.0
+ if forward.length() > 0.0001:
+ _last_move_forward = forward.normalized()
+ if right.length() > 0.0001:
+ _last_move_right = right.normalized()
+
+func _integrate_forces(state):
+ if cameraMoveMode and _pending_mouse_delta != Vector2.ZERO:
+ rotation_x -= _pending_mouse_delta.y * mouse_sensitivity
+ rotation_y -= _pending_mouse_delta.x * mouse_sensitivity
+ rotation_x = clamp(rotation_x, deg_to_rad(-90), deg_to_rad(90)) # Prevent flipping
+ _camera_pitch = rotation_x
+ rotation.y = rotation_y
+ _pending_mouse_delta = Vector2.ZERO
+
+ # Input as 2D vector
+ var input2v := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
+
+ if Input.is_action_just_pressed("player_phone"):
+ phone_visible = !phone_visible
+ if phone:
+ phone.visible = phone_visible
+
+ # Camera based movement
+ var forward := Vector3.FORWARD * -1.0
+ var right := Vector3.RIGHT
+ if cam:
+ forward = cam.global_transform.basis.z
+ right = cam.global_transform.basis.x
+ # Project onto ground plane so looking up/down doesn't kill movement.
+ forward.y = 0.0
+ right.y = 0.0
+ if forward.length() > 0.0001:
+ forward = forward.normalized()
+ _last_move_forward = forward
+ else:
+ forward = _last_move_forward
+ if right.length() > 0.0001:
+ right = right.normalized()
+ _last_move_right = right
+ else:
+ right = _last_move_right
+
+ var dir := (right * input2v.x + forward * input2v.y).normalized()
+ var target_v := dir * MOVE_SPEED
+
+ var ax := ACCELLERATION if dir != Vector3.ZERO else DECELLERATION
+ linear_velocity.x = move_toward(linear_velocity.x, target_v.x, ax * state.step)
+ linear_velocity.z = move_toward(linear_velocity.z, target_v.z, ax * state.step)
+
+ # Jump Logic
+ var on_floor = false
+ for i in state.get_contact_count():
+ var normal = state.get_contact_local_normal(i)
+ if normal.y > 0.5:
+ on_floor = true
+ break
+
+ if Input.is_action_just_pressed("ui_accept") and (on_floor or current_number_of_jumps == 1):
+ current_number_of_jumps = (current_number_of_jumps + 1) % 2
+ linear_velocity.y = JUMP_SPEED
+ audio_player.play()
+
+ if cam:
+ var target_yaw := global_transform.basis.get_euler().y
+ _camera_yaw = lerp_angle(_camera_yaw, target_yaw, camera_follow_speed * state.step)
+ var target_basis := Basis(Vector3.UP, _camera_yaw)
+ var target_pos := global_position + (target_basis * _camera_offset_local)
+ cam.global_position = cam.global_position.lerp(target_pos, camera_follow_speed * state.step)
+ cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
+
+func _input(event):
+ if event is InputEventMouseButton:
+ if event.button_index == MOUSE_BUTTON_MIDDLE:
+ if event.pressed:
+ cameraMoveMode = true
+ Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
+ else:
+ cameraMoveMode = false
+ Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
+
+ if event is InputEventMouseMotion and cameraMoveMode:
+ _pending_mouse_delta += event.relative
+
+ if event is InputEventMouseButton and event.pressed:
+ if event.button_index == MOUSE_BUTTON_WHEEL_UP:
+ zoom_camera(1.0 / ZOOM_FACTOR) # Zoom in
+ elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
+ zoom_camera(ZOOM_FACTOR) # Zoom out
+
+ if event.is_action_pressed("player_light"):
+ $SpotLight3D.visible = !$SpotLight3D.visible
+
+func zoom_camera(factor):
+ var new_fov = cam.fov * factor
+ cam.fov = clamp(new_fov, MIN_FOV, MAX_FOV)
+
diff --git a/game/scenes/player.gd.uid b/game/scenes/player.gd.uid
index 0aaff4f..9260e9d 100644
--- a/game/scenes/player.gd.uid
+++ b/game/scenes/player.gd.uid
@@ -1 +1 @@
-uid://bpxggc8nr6tf6
+uid://bpxggc8nr6tf6
diff --git a/game/themes/button_theme.tres b/game/themes/button_theme.tres
index db232c1..b8f3e0e 100644
--- a/game/themes/button_theme.tres
+++ b/game/themes/button_theme.tres
@@ -1,39 +1,39 @@
-[gd_resource type="Theme" load_steps=7 format=3 uid="uid://wpxmub0n2dr3"]
-
-[ext_resource type="FontFile" uid="uid://m5ceou0rk6j6" path="res://assets/fonts/PlayfairDisplay-VariableFont_wght.ttf" id="1_qnv5y"]
-
-[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_kwhvy"]
-
-[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_bpa63"]
-
-[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_6abfs"]
-
-[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_hguu3"]
-
-[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wu4fv"]
-
-[resource]
-Button/colors/font_color = Color(0.875, 0.875, 0.875, 1)
-Button/colors/font_disabled_color = Color(0.875, 0.875, 0.875, 0.5)
-Button/colors/font_focus_color = Color(0.95, 0.95, 0.95, 1)
-Button/colors/font_hover_color = Color(0.95, 0.95, 0.95, 1)
-Button/colors/font_hover_pressed_color = Color(1, 1, 1, 1)
-Button/colors/font_outline_color = Color(8.834152, 8.190666, 9.316767, 1)
-Button/colors/font_pressed_color = Color(1, 1, 1, 1)
-Button/colors/icon_disabled_color = Color(1, 1, 1, 0.4)
-Button/colors/icon_focus_color = Color(1, 1, 1, 1)
-Button/colors/icon_hover_color = Color(1, 1, 1, 1)
-Button/colors/icon_hover_pressed_color = Color(1, 1, 1, 1)
-Button/colors/icon_normal_color = Color(1, 1, 1, 1)
-Button/colors/icon_pressed_color = Color(1, 1, 1, 1)
-Button/constants/align_to_largest_stylebox = 0
-Button/constants/h_separation = 4
-Button/constants/icon_max_width = 0
-Button/constants/outline_size = 0
-Button/font_sizes/font_size = 32
-Button/fonts/font = ExtResource("1_qnv5y")
-Button/styles/disabled = SubResource("StyleBoxEmpty_kwhvy")
-Button/styles/focus = SubResource("StyleBoxEmpty_bpa63")
-Button/styles/hover = SubResource("StyleBoxEmpty_6abfs")
-Button/styles/normal = SubResource("StyleBoxEmpty_hguu3")
-Button/styles/pressed = SubResource("StyleBoxEmpty_wu4fv")
+[gd_resource type="Theme" load_steps=7 format=3 uid="uid://wpxmub0n2dr3"]
+
+[ext_resource type="FontFile" uid="uid://m5ceou0rk6j6" path="res://assets/fonts/PlayfairDisplay-VariableFont_wght.ttf" id="1_qnv5y"]
+
+[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_kwhvy"]
+
+[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_bpa63"]
+
+[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_6abfs"]
+
+[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_hguu3"]
+
+[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wu4fv"]
+
+[resource]
+Button/colors/font_color = Color(0.875, 0.875, 0.875, 1)
+Button/colors/font_disabled_color = Color(0.875, 0.875, 0.875, 0.5)
+Button/colors/font_focus_color = Color(0.95, 0.95, 0.95, 1)
+Button/colors/font_hover_color = Color(0.95, 0.95, 0.95, 1)
+Button/colors/font_hover_pressed_color = Color(1, 1, 1, 1)
+Button/colors/font_outline_color = Color(8.834152, 8.190666, 9.316767, 1)
+Button/colors/font_pressed_color = Color(1, 1, 1, 1)
+Button/colors/icon_disabled_color = Color(1, 1, 1, 0.4)
+Button/colors/icon_focus_color = Color(1, 1, 1, 1)
+Button/colors/icon_hover_color = Color(1, 1, 1, 1)
+Button/colors/icon_hover_pressed_color = Color(1, 1, 1, 1)
+Button/colors/icon_normal_color = Color(1, 1, 1, 1)
+Button/colors/icon_pressed_color = Color(1, 1, 1, 1)
+Button/constants/align_to_largest_stylebox = 0
+Button/constants/h_separation = 4
+Button/constants/icon_max_width = 0
+Button/constants/outline_size = 0
+Button/font_sizes/font_size = 32
+Button/fonts/font = ExtResource("1_qnv5y")
+Button/styles/disabled = SubResource("StyleBoxEmpty_kwhvy")
+Button/styles/focus = SubResource("StyleBoxEmpty_bpa63")
+Button/styles/hover = SubResource("StyleBoxEmpty_6abfs")
+Button/styles/normal = SubResource("StyleBoxEmpty_hguu3")
+Button/styles/pressed = SubResource("StyleBoxEmpty_wu4fv")
diff --git a/game/themes/title_font_theme.tres b/game/themes/title_font_theme.tres
index be92bdc..36fdd73 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/.gitignore b/microservices/.gitignore
index ba7a514..558fe14 100644
--- a/microservices/.gitignore
+++ b/microservices/.gitignore
@@ -1,428 +1,428 @@
-## Ignore Visual Studio temporary files, build results, and
-## files generated by popular Visual Studio add-ons.
-##
-## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
-
-# User-specific files
-*.rsuser
-*.suo
-*.user
-*.userosscache
-*.sln.docstates
-*.env
-
-# User-specific files (MonoDevelop/Xamarin Studio)
-*.userprefs
-
-# Mono auto generated files
-mono_crash.*
-
-# Build results
-[Dd]ebug/
-[Dd]ebugPublic/
-[Rr]elease/
-[Rr]eleases/
-
-[Dd]ebug/x64/
-[Dd]ebugPublic/x64/
-[Rr]elease/x64/
-[Rr]eleases/x64/
-bin/x64/
-obj/x64/
-
-[Dd]ebug/x86/
-[Dd]ebugPublic/x86/
-[Rr]elease/x86/
-[Rr]eleases/x86/
-bin/x86/
-obj/x86/
-
-[Ww][Ii][Nn]32/
-[Aa][Rr][Mm]/
-[Aa][Rr][Mm]64/
-[Aa][Rr][Mm]64[Ee][Cc]/
-bld/
-[Oo]bj/
-[Oo]ut/
-[Ll]og/
-[Ll]ogs/
-
-# Build results on 'Bin' directories
-**/[Bb]in/*
-# Uncomment if you have tasks that rely on *.refresh files to move binaries
-# (https://github.com/github/gitignore/pull/3736)
-#!**/[Bb]in/*.refresh
-
-# Visual Studio 2015/2017 cache/options directory
-.vs/
-# Uncomment if you have tasks that create the project's static files in wwwroot
-#wwwroot/
-
-# Visual Studio 2017 auto generated files
-Generated\ Files/
-
-# MSTest test Results
-[Tt]est[Rr]esult*/
-[Bb]uild[Ll]og.*
-*.trx
-
-# NUnit
-*.VisualState.xml
-TestResult.xml
-nunit-*.xml
-
-# Approval Tests result files
-*.received.*
-
-# Build Results of an ATL Project
-[Dd]ebugPS/
-[Rr]eleasePS/
-dlldata.c
-
-# Benchmark Results
-BenchmarkDotNet.Artifacts/
-
-# .NET Core
-project.lock.json
-project.fragment.lock.json
-artifacts/
-
-# ASP.NET Scaffolding
-ScaffoldingReadMe.txt
-
-# StyleCop
-StyleCopReport.xml
-
-# Files built by Visual Studio
-*_i.c
-*_p.c
-*_h.h
-*.ilk
-*.meta
-*.obj
-*.idb
-*.iobj
-*.pch
-*.pdb
-*.ipdb
-*.pgc
-*.pgd
-*.rsp
-# but not Directory.Build.rsp, as it configures directory-level build defaults
-!Directory.Build.rsp
-*.sbr
-*.tlb
-*.tli
-*.tlh
-*.tmp
-*.tmp_proj
-*_wpftmp.csproj
-*.log
-*.tlog
-*.vspscc
-*.vssscc
-.builds
-*.pidb
-*.svclog
-*.scc
-
-# Chutzpah Test files
-_Chutzpah*
-
-# Visual C++ cache files
-ipch/
-*.aps
-*.ncb
-*.opendb
-*.opensdf
-*.sdf
-*.cachefile
-*.VC.db
-*.VC.VC.opendb
-
-# Visual Studio profiler
-*.psess
-*.vsp
-*.vspx
-*.sap
-
-# Visual Studio Trace Files
-*.e2e
-
-# TFS 2012 Local Workspace
-$tf/
-
-# Guidance Automation Toolkit
-*.gpState
-
-# ReSharper is a .NET coding add-in
-_ReSharper*/
-*.[Rr]e[Ss]harper
-*.DotSettings.user
-
-# TeamCity is a build add-in
-_TeamCity*
-
-# DotCover is a Code Coverage Tool
-*.dotCover
-
-# AxoCover is a Code Coverage Tool
-.axoCover/*
-!.axoCover/settings.json
-
-# Coverlet is a free, cross platform Code Coverage Tool
-coverage*.json
-coverage*.xml
-coverage*.info
-
-# Visual Studio code coverage results
-*.coverage
-*.coveragexml
-
-# NCrunch
-_NCrunch_*
-.NCrunch_*
-.*crunch*.local.xml
-nCrunchTemp_*
-
-# MightyMoose
-*.mm.*
-AutoTest.Net/
-
-# Web workbench (sass)
-.sass-cache/
-
-# Installshield output folder
-[Ee]xpress/
-
-# DocProject is a documentation generator add-in
-DocProject/buildhelp/
-DocProject/Help/*.HxT
-DocProject/Help/*.HxC
-DocProject/Help/*.hhc
-DocProject/Help/*.hhk
-DocProject/Help/*.hhp
-DocProject/Help/Html2
-DocProject/Help/html
-
-# Click-Once directory
-publish/
-
-# Publish Web Output
-*.[Pp]ublish.xml
-*.azurePubxml
-# Note: Comment the next line if you want to checkin your web deploy settings,
-# but database connection strings (with potential passwords) will be unencrypted
-*.pubxml
-*.publishproj
-
-# Microsoft Azure Web App publish settings. Comment the next line if you want to
-# checkin your Azure Web App publish settings, but sensitive information contained
-# in these scripts will be unencrypted
-PublishScripts/
-
-# NuGet Packages
-*.nupkg
-# NuGet Symbol Packages
-*.snupkg
-# The packages folder can be ignored because of Package Restore
-**/[Pp]ackages/*
-# except build/, which is used as an MSBuild target.
-!**/[Pp]ackages/build/
-# Uncomment if necessary however generally it will be regenerated when needed
-#!**/[Pp]ackages/repositories.config
-# NuGet v3's project.json files produces more ignorable files
-*.nuget.props
-*.nuget.targets
-
-# Microsoft Azure Build Output
-csx/
-*.build.csdef
-
-# Microsoft Azure Emulator
-ecf/
-rcf/
-
-# Windows Store app package directories and files
-AppPackages/
-BundleArtifacts/
-Package.StoreAssociation.xml
-_pkginfo.txt
-*.appx
-*.appxbundle
-*.appxupload
-
-# Visual Studio cache files
-# files ending in .cache can be ignored
-*.[Cc]ache
-# but keep track of directories ending in .cache
-!?*.[Cc]ache/
-
-# Others
-ClientBin/
-~$*
-*~
-*.dbmdl
-*.dbproj.schemaview
-*.jfm
-*.pfx
-*.publishsettings
-orleans.codegen.cs
-
-# Including strong name files can present a security risk
-# (https://github.com/github/gitignore/pull/2483#issue-259490424)
-#*.snk
-
-# Since there are multiple workflows, uncomment next line to ignore bower_components
-# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
-#bower_components/
-
-# RIA/Silverlight projects
-Generated_Code/
-
-# Backup & report files from converting an old project file
-# to a newer Visual Studio version. Backup files are not needed,
-# because we have git ;-)
-_UpgradeReport_Files/
-Backup*/
-UpgradeLog*.XML
-UpgradeLog*.htm
-ServiceFabricBackup/
-*.rptproj.bak
-
-# SQL Server files
-*.mdf
-*.ldf
-*.ndf
-
-# Business Intelligence projects
-*.rdl.data
-*.bim.layout
-*.bim_*.settings
-*.rptproj.rsuser
-*- [Bb]ackup.rdl
-*- [Bb]ackup ([0-9]).rdl
-*- [Bb]ackup ([0-9][0-9]).rdl
-
-# Microsoft Fakes
-FakesAssemblies/
-
-# GhostDoc plugin setting file
-*.GhostDoc.xml
-
-# Node.js Tools for Visual Studio
-.ntvs_analysis.dat
-node_modules/
-
-# Visual Studio 6 build log
-*.plg
-
-# Visual Studio 6 workspace options file
-*.opt
-
-# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
-*.vbw
-
-# Visual Studio 6 workspace and project file (working project files containing files to include in project)
-*.dsw
-*.dsp
-
-# Visual Studio 6 technical files
-*.ncb
-*.aps
-
-# Visual Studio LightSwitch build output
-**/*.HTMLClient/GeneratedArtifacts
-**/*.DesktopClient/GeneratedArtifacts
-**/*.DesktopClient/ModelManifest.xml
-**/*.Server/GeneratedArtifacts
-**/*.Server/ModelManifest.xml
-_Pvt_Extensions
-
-# Paket dependency manager
-**/.paket/paket.exe
-paket-files/
-
-# FAKE - F# Make
-**/.fake/
-
-# CodeRush personal settings
-**/.cr/personal
-
-# Python Tools for Visual Studio (PTVS)
-**/__pycache__/
-*.pyc
-
-# Cake - Uncomment if you are using it
-#tools/**
-#!tools/packages.config
-
-# Tabs Studio
-*.tss
-
-# Telerik's JustMock configuration file
-*.jmconfig
-
-# BizTalk build output
-*.btp.cs
-*.btm.cs
-*.odx.cs
-*.xsd.cs
-
-# OpenCover UI analysis results
-OpenCover/
-
-# Azure Stream Analytics local run output
-ASALocalRun/
-
-# MSBuild Binary and Structured Log
-*.binlog
-MSBuild_Logs/
-
-# AWS SAM Build and Temporary Artifacts folder
-.aws-sam
-
-# NVidia Nsight GPU debugger configuration file
-*.nvuser
-
-# MFractors (Xamarin productivity tool) working folder
-**/.mfractor/
-
-# Local History for Visual Studio
-**/.localhistory/
-
-# Visual Studio History (VSHistory) files
-.vshistory/
-
-# BeatPulse healthcheck temp database
-healthchecksdb
-
-# Backup folder for Package Reference Convert tool in Visual Studio 2017
-MigrationBackup/
-
-# Ionide (cross platform F# VS Code tools) working folder
-**/.ionide/
-
-# Fody - auto-generated XML schema
-FodyWeavers.xsd
-
-# VS Code files for those working on multiple tools
-.vscode/*
-!.vscode/settings.json
-!.vscode/tasks.json
-!.vscode/launch.json
-!.vscode/extensions.json
-!.vscode/*.code-snippets
-
-# Local History for Visual Studio Code
-.history/
-
-# Built Visual Studio Code Extensions
-*.vsix
-
-# Windows Installer files from build outputs
-*.cab
-*.msi
-*.msix
-*.msm
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+*.env
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+
+[Dd]ebug/x64/
+[Dd]ebugPublic/x64/
+[Rr]elease/x64/
+[Rr]eleases/x64/
+bin/x64/
+obj/x64/
+
+[Dd]ebug/x86/
+[Dd]ebugPublic/x86/
+[Rr]elease/x86/
+[Rr]eleases/x86/
+bin/x86/
+obj/x86/
+
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+[Aa][Rr][Mm]64[Ee][Cc]/
+bld/
+[Oo]bj/
+[Oo]ut/
+[Ll]og/
+[Ll]ogs/
+
+# Build results on 'Bin' directories
+**/[Bb]in/*
+# Uncomment if you have tasks that rely on *.refresh files to move binaries
+# (https://github.com/github/gitignore/pull/3736)
+#!**/[Bb]in/*.refresh
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+*.trx
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Approval Tests result files
+*.received.*
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.idb
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+**/.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+**/.fake/
+
+# CodeRush personal settings
+**/.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+**/__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+#tools/**
+#!tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+MSBuild_Logs/
+
+# AWS SAM Build and Temporary Artifacts folder
+.aws-sam
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+**/.mfractor/
+
+# Local History for Visual Studio
+**/.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+**/.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
*.msp
\ No newline at end of file
diff --git a/microservices/AuthApi/AuthApi.csproj b/microservices/AuthApi/AuthApi.csproj
index 66c00d8..0aae074 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/AuthApi.csproj.user b/microservices/AuthApi/AuthApi.csproj.user
index 9ff5820..ccfffb1 100644
--- a/microservices/AuthApi/AuthApi.csproj.user
+++ b/microservices/AuthApi/AuthApi.csproj.user
@@ -1,6 +1,6 @@
-
-
-
- https
-
+
+
+
+ https
+
\ No newline at end of file
diff --git a/microservices/AuthApi/AuthApi.http b/microservices/AuthApi/AuthApi.http
index c6e6d30..3a4faad 100644
--- a/microservices/AuthApi/AuthApi.http
+++ b/microservices/AuthApi/AuthApi.http
@@ -1,6 +1,6 @@
-@AuthApi_HostAddress = http://localhost:5279
-
-GET {{AuthApi_HostAddress}}/weatherforecast/
-Accept: application/json
-
-###
+@AuthApi_HostAddress = http://localhost:5279
+
+GET {{AuthApi_HostAddress}}/weatherforecast/
+Accept: application/json
+
+###
diff --git a/microservices/AuthApi/Controllers/AuthController.cs b/microservices/AuthApi/Controllers/AuthController.cs
index d91fd07..192b9a6 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/DOCUMENTS.md b/microservices/AuthApi/DOCUMENTS.md
new file mode 100644
index 0000000..7aa9a40
--- /dev/null
+++ b/microservices/AuthApi/DOCUMENTS.md
@@ -0,0 +1,49 @@
+# AuthApi document shapes
+
+This service expects JSON request bodies for its auth endpoints and stores user
+documents in MongoDB.
+
+Inbound JSON documents
+- RegisterRequest (`POST /api/auth/register`)
+ ```json
+ {
+ "username": "string",
+ "password": "string",
+ "email": "string (optional)"
+ }
+ ```
+- LoginRequest (`POST /api/auth/login`)
+ ```json
+ {
+ "username": "string",
+ "password": "string"
+ }
+ ```
+- RefreshRequest (`POST /api/auth/refresh`)
+ ```json
+ {
+ "username": "string",
+ "refreshToken": "string"
+ }
+ ```
+- ChangeRoleRequest (`POST /api/auth/role`)
+ ```json
+ {
+ "username": "string",
+ "newRole": "USER | SUPER"
+ }
+ ```
+
+Stored documents (MongoDB)
+- User
+ ```json
+ {
+ "id": "string (ObjectId)",
+ "username": "string",
+ "passwordHash": "string",
+ "role": "USER | SUPER",
+ "email": "string (optional)",
+ "refreshToken": "string (optional)",
+ "refreshTokenExpiry": "string (optional, ISO-8601 datetime)"
+ }
+ ```
diff --git a/microservices/AuthApi/Dockerfile b/microservices/AuthApi/Dockerfile
index 128c610..d766fe4 100644
--- a/microservices/AuthApi/Dockerfile
+++ b/microservices/AuthApi/Dockerfile
@@ -1,21 +1,21 @@
-FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
-WORKDIR /src
-
-# Copy project file first to take advantage of Docker layer caching
-COPY ["AuthApi.csproj", "./"]
-RUN dotnet restore "AuthApi.csproj"
-
-# Copy the remaining source and publish
-COPY . .
-RUN dotnet publish "AuthApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
-
-FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
-WORKDIR /app
-COPY --from=build /app/publish .
-
-ENV ASPNETCORE_URLS=http://+:8080 \
- ASPNETCORE_ENVIRONMENT=Production
-
-EXPOSE 8080
-
-ENTRYPOINT ["dotnet", "AuthApi.dll"]
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /src
+
+# Copy project file first to take advantage of Docker layer caching
+COPY ["AuthApi.csproj", "./"]
+RUN dotnet restore "AuthApi.csproj"
+
+# Copy the remaining source and publish
+COPY . .
+RUN dotnet publish "AuthApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
+WORKDIR /app
+COPY --from=build /app/publish .
+
+ENV ASPNETCORE_URLS=http://+:8080 \
+ ASPNETCORE_ENVIRONMENT=Production
+
+EXPOSE 8080
+
+ENTRYPOINT ["dotnet", "AuthApi.dll"]
diff --git a/microservices/AuthApi/Models/Dto.cs b/microservices/AuthApi/Models/Dto.cs
index cd6897c..1b43cdc 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 3ff9d76..1c4ed9a 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 617173b..8a5729c 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 dcb9958..375332c 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/README.md b/microservices/AuthApi/README.md
new file mode 100644
index 0000000..68cb79b
--- /dev/null
+++ b/microservices/AuthApi/README.md
@@ -0,0 +1,12 @@
+# AuthApi
+
+## Document shapes
+See `DOCUMENTS.md` for request payloads and stored document shapes.
+
+## Endpoints
+- `POST /api/auth/register` Register a new user.
+- `POST /api/auth/login` Issue access and refresh tokens.
+- `POST /api/auth/refresh` Refresh an access token.
+- `POST /api/auth/logout` Revoke the current access token.
+- `POST /api/auth/role` Update a user's role (SUPER only).
+- `GET /api/auth/users` List users (SUPER only).
diff --git a/microservices/AuthApi/Services/BlacklistService.cs b/microservices/AuthApi/Services/BlacklistService.cs
index 958a558..58888a7 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 9bda631..eae3e4e 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 0c208ae..ff66ba6 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 40a1fb6..7996a21 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 8d6ff80..68fb120 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 8d90a50..10bbe42 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 0cb0950..ea4d0bc 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 835641a..77f0069 100644
--- a/microservices/CharacterApi/Controllers/CharactersController.cs
+++ b/microservices/CharacterApi/Controllers/CharactersController.cs
@@ -1,69 +1,69 @@
-using CharacterApi.Models;
-using CharacterApi.Services;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using System.Security.Claims;
-
-namespace CharacterApi.Controllers;
-
-[ApiController]
-[Route("api/[controller]")]
-public class CharactersController : ControllerBase
-{
- private readonly CharacterStore _characters;
-
- public CharactersController(CharacterStore characters)
- {
- _characters = characters;
- }
-
- [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 character = new Character
- {
- OwnerUserId = userId,
- Name = req.Name.Trim(),
- CreatedUtc = DateTime.UtcNow
- };
-
- await _characters.CreateAsync(character);
- return Ok(character);
- }
-
- [HttpGet]
- [Authorize(Roles = "USER,SUPER")]
- public async Task ListMine()
- {
- var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
- if (string.IsNullOrWhiteSpace(userId))
- return Unauthorized();
-
- var characters = await _characters.GetForOwnerAsync(userId);
- return Ok(characters);
- }
-
- [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");
- }
-}
+using CharacterApi.Models;
+using CharacterApi.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System.Security.Claims;
+
+namespace CharacterApi.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class CharactersController : ControllerBase
+{
+ private readonly CharacterStore _characters;
+
+ public CharactersController(CharacterStore characters)
+ {
+ _characters = characters;
+ }
+
+ [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 character = new Character
+ {
+ OwnerUserId = userId,
+ Name = req.Name.Trim(),
+ CreatedUtc = DateTime.UtcNow
+ };
+
+ await _characters.CreateAsync(character);
+ return Ok(character);
+ }
+
+ [HttpGet]
+ [Authorize(Roles = "USER,SUPER")]
+ public async Task ListMine()
+ {
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (string.IsNullOrWhiteSpace(userId))
+ return Unauthorized();
+
+ var characters = await _characters.GetForOwnerAsync(userId);
+ return Ok(characters);
+ }
+
+ [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");
+ }
+}
diff --git a/microservices/CharacterApi/DOCUMENTS.md b/microservices/CharacterApi/DOCUMENTS.md
new file mode 100644
index 0000000..c75b3da
--- /dev/null
+++ b/microservices/CharacterApi/DOCUMENTS.md
@@ -0,0 +1,23 @@
+# 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
+ {
+ "name": "string"
+ }
+ ```
+
+Stored documents (MongoDB)
+- Character
+ ```json
+ {
+ "id": "string (ObjectId)",
+ "ownerUserId": "string",
+ "name": "string",
+ "createdUtc": "string (ISO-8601 datetime)"
+ }
+ ```
diff --git a/microservices/CharacterApi/Dockerfile b/microservices/CharacterApi/Dockerfile
index 1e0c5f3..d63dcdb 100644
--- a/microservices/CharacterApi/Dockerfile
+++ b/microservices/CharacterApi/Dockerfile
@@ -1,21 +1,21 @@
-FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
-WORKDIR /src
-
-# Copy project file first to take advantage of Docker layer caching
-COPY ["CharacterApi.csproj", "./"]
-RUN dotnet restore "CharacterApi.csproj"
-
-# Copy the remaining source and publish
-COPY . .
-RUN dotnet publish "CharacterApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
-
-FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
-WORKDIR /app
-COPY --from=build /app/publish .
-
-ENV ASPNETCORE_URLS=http://+:8080 \
- ASPNETCORE_ENVIRONMENT=Production
-
-EXPOSE 8080
-
-ENTRYPOINT ["dotnet", "CharacterApi.dll"]
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /src
+
+# Copy project file first to take advantage of Docker layer caching
+COPY ["CharacterApi.csproj", "./"]
+RUN dotnet restore "CharacterApi.csproj"
+
+# Copy the remaining source and publish
+COPY . .
+RUN dotnet publish "CharacterApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
+WORKDIR /app
+COPY --from=build /app/publish .
+
+ENV ASPNETCORE_URLS=http://+:8080 \
+ ASPNETCORE_ENVIRONMENT=Production
+
+EXPOSE 8080
+
+ENTRYPOINT ["dotnet", "CharacterApi.dll"]
diff --git a/microservices/CharacterApi/Models/Character.cs b/microservices/CharacterApi/Models/Character.cs
index 88065de..7d26b1f 100644
--- a/microservices/CharacterApi/Models/Character.cs
+++ b/microservices/CharacterApi/Models/Character.cs
@@ -1,17 +1,17 @@
-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 DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
-}
+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 DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/microservices/CharacterApi/Models/CreateCharacterRequest.cs b/microservices/CharacterApi/Models/CreateCharacterRequest.cs
index 8033817..0cb8f46 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/Program.cs b/microservices/CharacterApi/Program.cs
index d8c01f9..bac4df0 100644
--- a/microservices/CharacterApi/Program.cs
+++ b/microservices/CharacterApi/Program.cs
@@ -1,72 +1,72 @@
-using CharacterApi.Services;
-using Microsoft.AspNetCore.Authentication.JwtBearer;
-using Microsoft.IdentityModel.Tokens;
-using Microsoft.OpenApi.Models;
-using System.Text;
-
-var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddControllers();
-
-// DI
-builder.Services.AddSingleton();
-
-// 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.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();
+using CharacterApi.Services;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.OpenApi.Models;
+using System.Text;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddControllers();
+
+// DI
+builder.Services.AddSingleton();
+
+// 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.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();
diff --git a/microservices/CharacterApi/Properties/launchSettings.json b/microservices/CharacterApi/Properties/launchSettings.json
index 374e9df..703262b 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/README.md b/microservices/CharacterApi/README.md
new file mode 100644
index 0000000..4c9db47
--- /dev/null
+++ b/microservices/CharacterApi/README.md
@@ -0,0 +1,9 @@
+# CharacterApi
+
+## Document shapes
+See `DOCUMENTS.md` for request payloads and stored document shapes.
+
+## Endpoints
+- `POST /api/characters` Create a character.
+- `GET /api/characters` List characters for the current user.
+- `DELETE /api/characters/{id}` Delete a character owned by the current user.
diff --git a/microservices/CharacterApi/Services/CharacterStore.cs b/microservices/CharacterApi/Services/CharacterStore.cs
index 65b0ee9..433b438 100644
--- a/microservices/CharacterApi/Services/CharacterStore.cs
+++ b/microservices/CharacterApi/Services/CharacterStore.cs
@@ -1,41 +1,41 @@
-using CharacterApi.Models;
-using MongoDB.Driver;
-
-namespace CharacterApi.Services;
-
-public class CharacterStore
-{
- private readonly IMongoCollection _col;
-
- public CharacterStore(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("Characters");
-
- var ownerIndex = Builders.IndexKeys.Ascending(c => c.OwnerUserId);
- _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex));
- }
-
- public Task CreateAsync(Character character) => _col.InsertOneAsync(character);
-
- public Task> GetForOwnerAsync(string ownerUserId) =>
- _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync();
-
- public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner)
- {
- var filter = Builders.Filter.Eq(c => c.Id, id);
- if (!allowAnyOwner)
- {
- filter = Builders.Filter.And(
- filter,
- Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId)
- );
- }
-
- var result = await _col.DeleteOneAsync(filter);
- return result.DeletedCount > 0;
- }
-}
+using CharacterApi.Models;
+using MongoDB.Driver;
+
+namespace CharacterApi.Services;
+
+public class CharacterStore
+{
+ private readonly IMongoCollection _col;
+
+ public CharacterStore(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("Characters");
+
+ var ownerIndex = Builders.IndexKeys.Ascending(c => c.OwnerUserId);
+ _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex));
+ }
+
+ public Task CreateAsync(Character character) => _col.InsertOneAsync(character);
+
+ public Task> GetForOwnerAsync(string ownerUserId) =>
+ _col.Find(c => c.OwnerUserId == ownerUserId).ToListAsync();
+
+ public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner)
+ {
+ var filter = Builders.Filter.Eq(c => c.Id, id);
+ if (!allowAnyOwner)
+ {
+ filter = Builders.Filter.And(
+ filter,
+ Builders.Filter.Eq(c => c.OwnerUserId, ownerUserId)
+ );
+ }
+
+ var result = await _col.DeleteOneAsync(filter);
+ return result.DeletedCount > 0;
+ }
+}
diff --git a/microservices/CharacterApi/appsettings.Development.json b/microservices/CharacterApi/appsettings.Development.json
index 92b9175..d50df68 100644
--- a/microservices/CharacterApi/appsettings.Development.json
+++ b/microservices/CharacterApi/appsettings.Development.json
@@ -1,6 +1,6 @@
-{
- "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } },
- "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
- "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
- "Logging": { "LogLevel": { "Default": "Information" } }
-}
+{
+ "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } },
+ "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
+ "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
+ "Logging": { "LogLevel": { "Default": "Information" } }
+}
diff --git a/microservices/CharacterApi/appsettings.json b/microservices/CharacterApi/appsettings.json
index c7c9512..2a44cad 100644
--- a/microservices/CharacterApi/appsettings.json
+++ b/microservices/CharacterApi/appsettings.json
@@ -1,7 +1,7 @@
-{
- "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5001" } } },
- "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:5001" } } },
+ "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/CharacterApi/k8s/deployment.yaml b/microservices/CharacterApi/k8s/deployment.yaml
index d92297c..3bee9a4 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 4a16f0e..c631062 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/LocationsApi/Controllers/LocationsController.cs b/microservices/LocationsApi/Controllers/LocationsController.cs
new file mode 100644
index 0000000..d716ded
--- /dev/null
+++ b/microservices/LocationsApi/Controllers/LocationsController.cs
@@ -0,0 +1,83 @@
+using LocationsApi.Models;
+using LocationsApi.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using System.Security.Claims;
+
+namespace LocationsApi.Controllers;
+
+[ApiController]
+[Route("api/[controller]")]
+public class LocationsController : ControllerBase
+{
+ private readonly LocationStore _locations;
+
+ public LocationsController(LocationStore locations)
+ {
+ _locations = locations;
+ }
+
+ [HttpPost]
+ [Authorize(Roles = "SUPER")]
+ public async Task Create([FromBody] CreateLocationRequest req)
+ {
+ if (string.IsNullOrWhiteSpace(req.Name))
+ return BadRequest("Name required");
+
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (string.IsNullOrWhiteSpace(userId))
+ return Unauthorized();
+
+ var location = new Location
+ {
+ OwnerUserId = userId,
+ Name = req.Name.Trim(),
+ CreatedUtc = DateTime.UtcNow
+ };
+
+ await _locations.CreateAsync(location);
+ return Ok(location);
+ }
+
+ [HttpGet]
+ [Authorize(Roles = "USER,SUPER")]
+ public async Task ListMine()
+ {
+ var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
+ if (string.IsNullOrWhiteSpace(userId))
+ return Unauthorized();
+
+ var locations = await _locations.GetForOwnerAsync(userId);
+ return Ok(locations);
+ }
+
+ [HttpDelete("{id}")]
+ [Authorize(Roles = "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 _locations.DeleteForOwnerAsync(id, userId, allowAnyOwner);
+ if (!deleted)
+ return NotFound();
+
+ return Ok("Deleted");
+ }
+
+ [HttpPut("{id}")]
+ [Authorize(Roles = "SUPER")]
+ public async Task Update(string id, [FromBody] UpdateLocationRequest req)
+ {
+ if (string.IsNullOrWhiteSpace(req.Name))
+ return BadRequest("Name required");
+
+ var updated = await _locations.UpdateNameAsync(id, req.Name.Trim());
+ if (!updated)
+ return NotFound();
+
+ return Ok("Updated");
+ }
+}
diff --git a/microservices/LocationsApi/DOCUMENTS.md b/microservices/LocationsApi/DOCUMENTS.md
new file mode 100644
index 0000000..d60c923
--- /dev/null
+++ b/microservices/LocationsApi/DOCUMENTS.md
@@ -0,0 +1,29 @@
+# LocationsApi document shapes
+
+This service expects JSON request bodies for location creation and updates and
+stores location documents in MongoDB.
+
+Inbound JSON documents
+- CreateLocationRequest (`POST /api/locations`)
+ ```json
+ {
+ "name": "string"
+ }
+ ```
+- UpdateLocationRequest (`PUT /api/locations/{id}`)
+ ```json
+ {
+ "name": "string"
+ }
+ ```
+
+Stored documents (MongoDB)
+- Location
+ ```json
+ {
+ "id": "string (ObjectId)",
+ "ownerUserId": "string",
+ "name": "string",
+ "createdUtc": "string (ISO-8601 datetime)"
+ }
+ ```
diff --git a/microservices/LocationsApi/Dockerfile b/microservices/LocationsApi/Dockerfile
new file mode 100644
index 0000000..33a2128
--- /dev/null
+++ b/microservices/LocationsApi/Dockerfile
@@ -0,0 +1,21 @@
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+WORKDIR /src
+
+# Copy project file first to take advantage of Docker layer caching
+COPY ["LocationsApi.csproj", "./"]
+RUN dotnet restore "LocationsApi.csproj"
+
+# Copy the remaining source and publish
+COPY . .
+RUN dotnet publish "LocationsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
+WORKDIR /app
+COPY --from=build /app/publish .
+
+ENV ASPNETCORE_URLS=http://+:8080 \
+ ASPNETCORE_ENVIRONMENT=Production
+
+EXPOSE 8080
+
+ENTRYPOINT ["dotnet", "LocationsApi.dll"]
diff --git a/microservices/LocationsApi/LocationsApi.csproj b/microservices/LocationsApi/LocationsApi.csproj
new file mode 100644
index 0000000..0cb0950
--- /dev/null
+++ b/microservices/LocationsApi/LocationsApi.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/microservices/LocationsApi/Models/CreateLocationRequest.cs b/microservices/LocationsApi/Models/CreateLocationRequest.cs
new file mode 100644
index 0000000..6237da9
--- /dev/null
+++ b/microservices/LocationsApi/Models/CreateLocationRequest.cs
@@ -0,0 +1,6 @@
+namespace LocationsApi.Models;
+
+public class CreateLocationRequest
+{
+ public string Name { get; set; } = string.Empty;
+}
diff --git a/microservices/LocationsApi/Models/Location.cs b/microservices/LocationsApi/Models/Location.cs
new file mode 100644
index 0000000..dd96454
--- /dev/null
+++ b/microservices/LocationsApi/Models/Location.cs
@@ -0,0 +1,17 @@
+using MongoDB.Bson;
+using MongoDB.Bson.Serialization.Attributes;
+
+namespace LocationsApi.Models;
+
+public class Location
+{
+ [BsonId]
+ [BsonRepresentation(BsonType.ObjectId)]
+ public string? Id { get; set; }
+
+ public string OwnerUserId { get; set; } = string.Empty;
+
+ public string Name { get; set; } = string.Empty;
+
+ public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/microservices/LocationsApi/Models/UpdateLocationRequest.cs b/microservices/LocationsApi/Models/UpdateLocationRequest.cs
new file mode 100644
index 0000000..49c8179
--- /dev/null
+++ b/microservices/LocationsApi/Models/UpdateLocationRequest.cs
@@ -0,0 +1,6 @@
+namespace LocationsApi.Models;
+
+public class UpdateLocationRequest
+{
+ public string Name { get; set; } = string.Empty;
+}
diff --git a/microservices/LocationsApi/Program.cs b/microservices/LocationsApi/Program.cs
new file mode 100644
index 0000000..c4da181
--- /dev/null
+++ b/microservices/LocationsApi/Program.cs
@@ -0,0 +1,72 @@
+using LocationsApi.Services;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.OpenApi.Models;
+using System.Text;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddControllers();
+
+// DI
+builder.Services.AddSingleton();
+
+// Swagger + JWT auth in Swagger
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo { Title = "Locations 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.MapGet("/healthz", () => Results.Ok("ok"));
+app.UseSwagger();
+app.UseSwaggerUI(o =>
+{
+ o.SwaggerEndpoint("/swagger/v1/swagger.json", "Locations API v1");
+ o.RoutePrefix = "swagger";
+});
+app.UseAuthentication();
+app.UseAuthorization();
+app.MapControllers();
+app.Run();
diff --git a/microservices/LocationsApi/Properties/launchSettings.json b/microservices/LocationsApi/Properties/launchSettings.json
new file mode 100644
index 0000000..5e531cd
--- /dev/null
+++ b/microservices/LocationsApi/Properties/launchSettings.json
@@ -0,0 +1,12 @@
+{
+ "profiles": {
+ "LocationsApi": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ },
+ "applicationUrl": "https://localhost:50786;http://localhost:50787"
+ }
+ }
+}
diff --git a/microservices/LocationsApi/README.md b/microservices/LocationsApi/README.md
new file mode 100644
index 0000000..a5d759b
--- /dev/null
+++ b/microservices/LocationsApi/README.md
@@ -0,0 +1,10 @@
+# LocationsApi
+
+## Document shapes
+See `DOCUMENTS.md` for request payloads and stored document shapes.
+
+## Endpoints
+- `POST /api/locations` Create a location (SUPER only).
+- `GET /api/locations` List locations for the current user.
+- `DELETE /api/locations/{id}` Delete a location (SUPER only).
+- `PUT /api/locations/{id}` Update a location name (SUPER only).
diff --git a/microservices/LocationsApi/Services/LocationStore.cs b/microservices/LocationsApi/Services/LocationStore.cs
new file mode 100644
index 0000000..b000b97
--- /dev/null
+++ b/microservices/LocationsApi/Services/LocationStore.cs
@@ -0,0 +1,49 @@
+using LocationsApi.Models;
+using MongoDB.Driver;
+
+namespace LocationsApi.Services;
+
+public class LocationStore
+{
+ private readonly IMongoCollection _col;
+
+ public LocationStore(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("Locations");
+
+ var ownerIndex = Builders.IndexKeys.Ascending(l => l.OwnerUserId);
+ _col.Indexes.CreateOne(new CreateIndexModel(ownerIndex));
+ }
+
+ public Task CreateAsync(Location location) => _col.InsertOneAsync(location);
+
+ public Task> GetForOwnerAsync(string ownerUserId) =>
+ _col.Find(l => l.OwnerUserId == ownerUserId).ToListAsync();
+
+ public async Task DeleteForOwnerAsync(string id, string ownerUserId, bool allowAnyOwner)
+ {
+ var filter = Builders.Filter.Eq(l => l.Id, id);
+ if (!allowAnyOwner)
+ {
+ filter = Builders.Filter.And(
+ filter,
+ Builders.Filter.Eq(l => l.OwnerUserId, ownerUserId)
+ );
+ }
+
+ 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);
+ var update = Builders.Update.Set(l => l.Name, name);
+ var result = await _col.UpdateOneAsync(filter, update);
+ return result.ModifiedCount > 0;
+ }
+}
diff --git a/microservices/LocationsApi/appsettings.Development.json b/microservices/LocationsApi/appsettings.Development.json
new file mode 100644
index 0000000..07f3f94
--- /dev/null
+++ b/microservices/LocationsApi/appsettings.Development.json
@@ -0,0 +1,6 @@
+{
+ "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
+ "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "promiscuity" },
+ "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
+ "Logging": { "LogLevel": { "Default": "Information" } }
+}
diff --git a/microservices/LocationsApi/appsettings.json b/microservices/LocationsApi/appsettings.json
new file mode 100644
index 0000000..d67c59f
--- /dev/null
+++ b/microservices/LocationsApi/appsettings.json
@@ -0,0 +1,7 @@
+{
+ "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:5002" } } },
+ "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/LocationsApi/k8s/deployment.yaml b/microservices/LocationsApi/k8s/deployment.yaml
new file mode 100644
index 0000000..edd5e16
--- /dev/null
+++ b/microservices/LocationsApi/k8s/deployment.yaml
@@ -0,0 +1,28 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: promiscuity-locations
+ labels:
+ app: promiscuity-locations
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: promiscuity-locations
+ template:
+ metadata:
+ labels:
+ app: promiscuity-locations
+ spec:
+ containers:
+ - name: promiscuity-locations
+ image: promiscuity-locations:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 5002
+ readinessProbe:
+ httpGet:
+ path: /healthz
+ port: 5002
+ initialDelaySeconds: 5
+ periodSeconds: 10
diff --git a/microservices/LocationsApi/k8s/service.yaml b/microservices/LocationsApi/k8s/service.yaml
new file mode 100644
index 0000000..2f8b314
--- /dev/null
+++ b/microservices/LocationsApi/k8s/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: promiscuity-locations
+ labels:
+ app: promiscuity-locations
+spec:
+ selector:
+ app: promiscuity-locations
+ type: NodePort
+ ports:
+ - name: http
+ port: 80 # cluster port
+ targetPort: 5002 # container port
+ nodePort: 30082 # external port
diff --git a/microservices/README.md b/microservices/README.md
index 1b199f0..f849d14 100644
--- a/microservices/README.md
+++ b/microservices/README.md
@@ -1,2 +1,12 @@
# micro-services
+## Document shapes
+- AuthApi: `AuthApi/DOCUMENTS.md` (auth request payloads and user document shape)
+- CharacterApi: `CharacterApi/DOCUMENTS.md` (character create payload and stored document)
+- LocationsApi: `LocationsApi/DOCUMENTS.md` (location create/update payloads and stored document)
+
+## Service READMEs
+- AuthApi: `AuthApi/README.md`
+- CharacterApi: `CharacterApi/README.md`
+- LocationsApi: `LocationsApi/README.md`
+
diff --git a/microservices/micro-services.sln b/microservices/micro-services.sln
index 9fe0c4f..4d1a08d 100644
--- a/microservices/micro-services.sln
+++ b/microservices/micro-services.sln
@@ -1,30 +1,36 @@
-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
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- 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
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocationsApi", "LocationsApi\LocationsApi.csproj", "{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ 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
{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
{1572BA36-8EFC-4472-BE74-0676B593AED9}.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
+ {C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.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