Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ffe6e7b7 | ||
|
|
4121c59484 | ||
|
|
c267b62afa | ||
|
|
cde0e28e44 | ||
|
|
18c3d6fc5d | ||
|
|
942cd59db8 | ||
|
|
7faef2e976 | ||
|
|
7f21b40d93 | ||
|
|
3b21cf3182 | ||
|
|
9e3df6c691 | ||
|
|
2cb8a7fb8f | ||
|
|
09e6b4cc90 | ||
|
|
e6a442c98b | ||
|
|
cce8fd3280 |
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "microservices/AuthApi/**"
|
||||
- ".gitea/workflows/deploy-auth.yml"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
@@ -20,6 +23,16 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Reclaim Docker build space
|
||||
run: |
|
||||
rm -f "${IMAGE_TAR}"
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
docker image prune -af --filter "until=24h"
|
||||
docker builder prune -af || true
|
||||
docker system df
|
||||
df -h
|
||||
|
||||
# -----------------------------
|
||||
# Build Docker image
|
||||
# -----------------------------
|
||||
@@ -59,18 +72,22 @@ jobs:
|
||||
# CLEANUP: delete TAR from nodes
|
||||
# -----------------------------
|
||||
- name: Clean TAR from nodes
|
||||
if: always()
|
||||
run: |
|
||||
for node in ${NODES}; do
|
||||
echo "Removing image tar on $node ..."
|
||||
ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-auth.tar"
|
||||
ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f /tmp/promiscuity-auth.tar" || true
|
||||
done
|
||||
|
||||
# -----------------------------
|
||||
# CLEANUP: delete TAR from runner
|
||||
# -----------------------------
|
||||
- name: Clean TAR on runner
|
||||
if: always()
|
||||
run: |
|
||||
rm -f "${IMAGE_TAR}"
|
||||
docker image rm "${IMAGE_NAME}" 2>/dev/null || true
|
||||
docker image prune -f
|
||||
|
||||
# -----------------------------
|
||||
# Write kubeconfig from secret
|
||||
@@ -91,6 +108,16 @@ jobs:
|
||||
run: |
|
||||
kubectl create namespace promiscuity-auth --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Configure internal API secret
|
||||
env:
|
||||
KUBECONFIG: /tmp/kube/config
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.INTERNAL_PURGE_SECRET }}
|
||||
run: |
|
||||
test -n "$INTERNAL_PURGE_SECRET"
|
||||
kubectl create secret generic medmind-internal-api \
|
||||
--from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \
|
||||
--dry-run=client -o yaml | kubectl apply -n promiscuity-auth -f -
|
||||
|
||||
# -----------------------------
|
||||
# Apply Kubernetes manifests
|
||||
# (You create these files in your repo)
|
||||
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "microservices/CharacterApi/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "microservices/CraftingApi/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "microservices/InventoryApi/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "microservices/LocationsApi/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "microservices/MailApi/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "microservices/WorldApi/**"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -22,3 +22,5 @@
|
||||
- Fireball: Shift + B
|
||||
- Phone: Tab
|
||||
- Pause menu: Esc
|
||||
- Toggle first/third person: V
|
||||
- Zoom (in/out): Scroll Wheel (snaps to First Person on zoom in)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://dgt3nox7yak72"
|
||||
path="res://.godot/imported/pop.ogg-e15d202808be009bef5e04746dba8616.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/pop.ogg"
|
||||
dest_files=["res://.godot/imported/pop.ogg-e15d202808be009bef5e04746dba8616.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://b2e0ls6ymw45i"
|
||||
path="res://.godot/imported/car_brake.wav-359538a9b1ba4240ae07e54477629dbb.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/vehicles/car/car_brake.wav"
|
||||
dest_files=["res://.godot/imported/car_brake.wav-359538a9b1ba4240ae07e54477629dbb.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
@@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://dlg100ft5f2jy"
|
||||
path="res://.godot/imported/car_engine_accel_loop.wav-25b55bfc6e1d41b17513ac3dd914f463.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/vehicles/car/car_engine_accel_loop.wav"
|
||||
dest_files=["res://.godot/imported/car_engine_accel_loop.wav-25b55bfc6e1d41b17513ac3dd914f463.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=1
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
@@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://cljhbkwcswalc"
|
||||
path="res://.godot/imported/car_engine_drive_loop.wav-be0d54cca48b174deaff89a90d5364c3.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/vehicles/car/car_engine_drive_loop.wav"
|
||||
dest_files=["res://.godot/imported/car_engine_drive_loop.wav-be0d54cca48b174deaff89a90d5364c3.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=1
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
@@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://c28v6t3txgn8w"
|
||||
path="res://.godot/imported/car_engine_idle_loop.wav-56323a2fbc32742f6519b3630f97c275.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/vehicles/car/car_engine_idle_loop.wav"
|
||||
dest_files=["res://.godot/imported/car_engine_idle_loop.wav-56323a2fbc32742f6519b3630f97c275.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=1
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
@@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://bhmyl3ie10u53"
|
||||
path="res://.godot/imported/car_enter.wav-aeff3918a465be3f557bb484cf84ed62.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/vehicles/car/car_enter.wav"
|
||||
dest_files=["res://.godot/imported/car_enter.wav-aeff3918a465be3f557bb484cf84ed62.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
@@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://bngm3s2yq2xwi"
|
||||
path="res://.godot/imported/car_exit.wav-f78ac0b868ae38aeb13d15881f6c3491.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/vehicles/car/car_exit.wav"
|
||||
dest_files=["res://.godot/imported/car_exit.wav-f78ac0b868ae38aeb13d15881f6c3491.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
@@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://bq2d35mekg4il"
|
||||
path="res://.godot/imported/car_hit.wav-5f5ca7b335ab69bea356887a03a1eacc.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/vehicles/car/car_hit.wav"
|
||||
dest_files=["res://.godot/imported/car_hit.wav-5f5ca7b335ab69bea356887a03a1eacc.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
@@ -0,0 +1,24 @@
|
||||
[remap]
|
||||
|
||||
importer="wav"
|
||||
type="AudioStreamWAV"
|
||||
uid="uid://y38ybkt3uxik"
|
||||
path="res://.godot/imported/car_horn.wav-4f978ca1d198f5c6c4a5fcfab9ca1ccc.sample"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/vehicles/car/car_horn.wav"
|
||||
dest_files=["res://.godot/imported/car_horn.wav-4f978ca1d198f5c6c4a5fcfab9ca1ccc.sample"]
|
||||
|
||||
[params]
|
||||
|
||||
force/8_bit=false
|
||||
force/mono=false
|
||||
force/max_rate=false
|
||||
force/max_rate_hz=44100
|
||||
edit/trim=false
|
||||
edit/normalize=false
|
||||
edit/loop_mode=0
|
||||
edit/loop_begin=0
|
||||
edit/loop_end=-1
|
||||
compress/mode=2
|
||||
@@ -0,0 +1,21 @@
|
||||
Copyright (c) 2023 Manik Sharma.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
Car Demo by Manik Sharma
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://civr7hlbf2kqb"
|
||||
path="res://.godot/imported/Wheel.glb-dddddd96691989c3e912750c9a47a858.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/Wheel.glb"
|
||||
dest_files=["res://.godot/imported/Wheel.glb-dddddd96691989c3e912750c9a47a858.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bpklxkc61avg6"
|
||||
path.s3tc="res://.godot/imported/Wheel_RIMMUSCLE_01_DIF.png-5bb5174c5332f74f16abee062d1ed24a.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "cd5730d572d3e9053a526f83b6971226"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/Wheel_RIMMUSCLE_01_DIF.png"
|
||||
dest_files=["res://.godot/imported/Wheel_RIMMUSCLE_01_DIF.png-5bb5174c5332f74f16abee062d1ed24a.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://djs3o5hanxb70"
|
||||
path.s3tc="res://.godot/imported/Wheel_RIMMUSCLE_01_MET-RIMMUSCLE_01_ROUGH.png-bf036ae2ba8ef4208853e0caa49ac9e7.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "ee91568e12f40d7fce962016fe4791c0"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/Wheel_RIMMUSCLE_01_MET-RIMMUSCLE_01_ROUGH.png"
|
||||
dest_files=["res://.godot/imported/Wheel_RIMMUSCLE_01_MET-RIMMUSCLE_01_ROUGH.png-bf036ae2ba8ef4208853e0caa49ac9e7.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cxufd33f0bqsx"
|
||||
path.s3tc="res://.godot/imported/Wheel_RIMMUSCLE_01_NRM.png-3149169313dea5a53591f1b5ae2411f6.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "7d86721466688c1cd90634fe6d2f953a"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/Wheel_RIMMUSCLE_01_NRM.png"
|
||||
dest_files=["res://.godot/imported/Wheel_RIMMUSCLE_01_NRM.png-3149169313dea5a53591f1b5ae2411f6.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=1
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=1
|
||||
roughness/src_normal="res://assets/models/vehicles/doge/Wheel_RIMMUSCLE_01_NRM.png"
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
@@ -0,0 +1,42 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://dvgiooankbnw3"
|
||||
path="res://.godot/imported/doge-body.glb-d066dc7fd60eda6d05736bc65e3c5ffe.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/doge-body.glb"
|
||||
dest_files=["res://.godot/imported/doge-body.glb-d066dc7fd60eda6d05736bc65e3c5ffe.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
|
After Width: | Height: | Size: 2.1 MiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dx401inrlplo1"
|
||||
path.s3tc="res://.godot/imported/doge-body_GEN4DR_INTERIOR_dif.png-b5a1b7fcdb7b3160f7a0ef7e265e4505.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "b3caf55dd0d9e1e05294308101435166"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/doge-body_GEN4DR_INTERIOR_dif.png"
|
||||
dest_files=["res://.godot/imported/doge-body_GEN4DR_INTERIOR_dif.png-b5a1b7fcdb7b3160f7a0ef7e265e4505.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
|
After Width: | Height: | Size: 222 KiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c63xvtkbodiob"
|
||||
path.s3tc="res://.godot/imported/doge-body_GENERIC_BADGES_DIF.png-796b015ef1e73a8329f18564cd4000af.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "f8fce006f08ef57c4bb09b493a6126d8"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/doge-body_GENERIC_BADGES_DIF.png"
|
||||
dest_files=["res://.godot/imported/doge-body_GENERIC_BADGES_DIF.png-796b015ef1e73a8329f18564cd4000af.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
|
After Width: | Height: | Size: 2.7 MiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://e5okom0atmrt"
|
||||
path.s3tc="res://.godot/imported/doge-body_Numberplates_dif.png-c03cf9c73e5d679f02921a9bb6c29d63.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "da3da05f00d3caef6299f64ac091b52f"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/doge-body_Numberplates_dif.png"
|
||||
dest_files=["res://.godot/imported/doge-body_Numberplates_dif.png-c03cf9c73e5d679f02921a9bb6c29d63.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
|
After Width: | Height: | Size: 942 KiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://2sf47hajdi3w"
|
||||
path.s3tc="res://.godot/imported/doge-body_Riverside88_lights_dif.png-05ac8d7af58447f0ddebfe66c3707ac9.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "c3cf8ceff55bcc14da25561e2939d3be"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/doge-body_Riverside88_lights_dif.png"
|
||||
dest_files=["res://.godot/imported/doge-body_Riverside88_lights_dif.png-05ac8d7af58447f0ddebfe66c3707ac9.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
|
After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://c83x8syn3olne"
|
||||
path.s3tc="res://.godot/imported/doge-body_UCB_BOTTOM_DIF.png-cfc19977dbe1d864173f653d08cb0672.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "0eabb927a9e9ac2e0de40610dbadc3a6"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/doge-body_UCB_BOTTOM_DIF.png"
|
||||
dest_files=["res://.godot/imported/doge-body_UCB_BOTTOM_DIF.png-cfc19977dbe1d864173f653d08cb0672.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
|
After Width: | Height: | Size: 3.4 MiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://crxpnsmfsorf5"
|
||||
path.s3tc="res://.godot/imported/doge-body_UCB_BOTTOM_nrm.png-b5e3eff64de1f5b644e5d70c8b7bfe37.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "ce8201694c51a8565f01b6f7688262bf"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/doge-body_UCB_BOTTOM_nrm.png"
|
||||
dest_files=["res://.godot/imported/doge-body_UCB_BOTTOM_nrm.png-b5e3eff64de1f5b644e5d70c8b7bfe37.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=1
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=1
|
||||
roughness/src_normal="res://assets/models/vehicles/doge/doge-body_UCB_BOTTOM_nrm.png"
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dxcjtnnch0sxr"
|
||||
path.s3tc="res://.godot/imported/doge-body_UCB_GLASS_CLEAN_dif.png-423a718e8cd643fb68b5ef6cfd665641.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
generator_parameters={
|
||||
"md5": "f8a5958ba2b852aaf7e4733c7ca90a83"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/models/vehicles/doge/doge-body_UCB_GLASS_CLEAN_dif.png"
|
||||
dest_files=["res://.godot/imported/doge-body_UCB_GLASS_CLEAN_dif.png-423a718e8cd643fb68b5ef6cfd665641.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=true
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=0
|
||||
@@ -116,6 +116,11 @@ player_phone={
|
||||
"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)
|
||||
]
|
||||
}
|
||||
player_toggle_perspective={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":86,"key_label":0,"unicode":118,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
interact={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
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.
|
||||
signal vehicle_entered(vehicle: Node)
|
||||
signal vehicle_exited(vehicle: Node)
|
||||
|
||||
const MOVE_SPEED := 8.0
|
||||
const SPRINT_MOVE_SPEED :=13
|
||||
const ACCELLERATION := 30.0
|
||||
const DECELLERATION := 40.0
|
||||
const JUMP_SPEED := 4.0
|
||||
const MAX_NUMBER_OF_JUMPS := 2
|
||||
const MIN_FOV := 10.0
|
||||
const MAX_FOV := 179.0
|
||||
const ZOOM_FACTOR := 1.1 # Zoom out when >1, in when < 1
|
||||
const FIREBALL_SPAWN_FORWARD_OFFSET := 1.5
|
||||
const FIREBALL_SPAWN_UP_OFFSET := 1.2
|
||||
var mouse_sensitivity := 0.005
|
||||
var rotation_x := 0.0
|
||||
var rotation_y := 0.0
|
||||
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
|
||||
var _in_vehicle := false
|
||||
var _vehicle_collision_layer := 0
|
||||
var _vehicle_collision_mask := 0
|
||||
var _vehicle_original_parent: Node = null
|
||||
var _light_was_on := false
|
||||
var _jump_triggered := false
|
||||
@onready var _flashlight: SpotLight3D = $SpotLight3D
|
||||
@onready var _anim_player: AnimationPlayer = find_child("AnimationPlayer", true, false) as AnimationPlayer
|
||||
@onready var _anim_tree: AnimationTree = find_child("AnimationTree", true, false) as AnimationTree
|
||||
@onready var _model_root: Node3D = find_child("TestCharAnimated", true, false) as Node3D
|
||||
|
||||
@export var camera_follow_speed := 10.0
|
||||
@export var anim_idle_name := "Idle"
|
||||
@export var anim_walk_name := "Walk"
|
||||
@export var anim_jump_name := "Jump"
|
||||
@export var anim_run_name := "Run"
|
||||
@export var anim_walk_speed_threshold := 0.25
|
||||
@export var anim_sprint_speed_threshold := 10.0
|
||||
|
||||
var jump_sound = preload("res://assets/audio/jump.ogg")
|
||||
var fireball_scene = preload("res://scenes/Projectiles/fireball.tscn")
|
||||
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:
|
||||
add_to_group("player")
|
||||
if _anim_tree:
|
||||
_anim_tree.active = false
|
||||
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()
|
||||
_vehicle_collision_layer = collision_layer
|
||||
_vehicle_collision_mask = collision_mask
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
func _integrate_forces(state):
|
||||
if _in_vehicle:
|
||||
linear_velocity = Vector3.ZERO
|
||||
return
|
||||
if _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))
|
||||
_camera_pitch = rotation_x
|
||||
rotation.y = rotation_y
|
||||
_pending_mouse_delta = Vector2.ZERO
|
||||
|
||||
var input2v := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
|
||||
var forward := Vector3.FORWARD * -1.0
|
||||
var right := Vector3.RIGHT
|
||||
if cam:
|
||||
forward = cam.global_transform.basis.z
|
||||
right = cam.global_transform.basis.x
|
||||
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
|
||||
if Input.is_key_pressed(KEY_SHIFT):
|
||||
target_v = dir * SPRINT_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)
|
||||
|
||||
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()
|
||||
_jump_triggered = true
|
||||
|
||||
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)
|
||||
|
||||
_update_animation(on_floor, state.linear_velocity)
|
||||
_jump_triggered = false
|
||||
|
||||
func _input(event):
|
||||
if event.is_action_pressed("player_phone"):
|
||||
phone_visible = !phone_visible
|
||||
if phone:
|
||||
phone.visible = phone_visible
|
||||
if phone_visible:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
else:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
return
|
||||
|
||||
if _in_vehicle:
|
||||
return
|
||||
|
||||
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
_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)
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
zoom_camera(ZOOM_FACTOR)
|
||||
|
||||
if event.is_action_pressed("player_fireball"):
|
||||
shoot_fireball()
|
||||
return
|
||||
|
||||
if event.is_action_pressed("player_light"):
|
||||
_flashlight.visible = !_flashlight.visible
|
||||
|
||||
func zoom_camera(factor):
|
||||
if cam == null:
|
||||
return
|
||||
var new_fov = cam.fov * factor
|
||||
cam.fov = clamp(new_fov, MIN_FOV, MAX_FOV)
|
||||
|
||||
func shoot_fireball() -> void:
|
||||
var direction := get_look_direction()
|
||||
var origin := get_look_origin() + direction * FIREBALL_SPAWN_FORWARD_OFFSET
|
||||
var fireball := fireball_scene.instantiate()
|
||||
get_tree().current_scene.add_child(fireball)
|
||||
fireball.launch(origin, direction)
|
||||
|
||||
func _update_animation(on_floor: bool, velocity: Vector3) -> void:
|
||||
if _anim_player == null:
|
||||
return
|
||||
var horizontal_speed := Vector3(velocity.x, 0.0, velocity.z).length()
|
||||
if _jump_triggered and _anim_player.has_animation(anim_jump_name):
|
||||
if _anim_player.current_animation != anim_jump_name:
|
||||
_anim_player.play(anim_jump_name)
|
||||
return
|
||||
if not on_floor and _anim_player.has_animation(anim_jump_name):
|
||||
if _anim_player.current_animation != anim_jump_name:
|
||||
_anim_player.play(anim_jump_name)
|
||||
return
|
||||
if on_floor and horizontal_speed > anim_sprint_speed_threshold and _anim_player.has_animation(anim_run_name):
|
||||
if _anim_player.current_animation != anim_run_name:
|
||||
_anim_player.play(anim_run_name)
|
||||
return
|
||||
if horizontal_speed > anim_walk_speed_threshold and _anim_player.has_animation(anim_walk_name):
|
||||
if _anim_player.current_animation != anim_walk_name:
|
||||
_anim_player.play(anim_walk_name)
|
||||
return
|
||||
if _anim_player.has_animation(anim_idle_name):
|
||||
if _anim_player.current_animation != anim_idle_name:
|
||||
_anim_player.play(anim_idle_name)
|
||||
|
||||
func enter_vehicle(_vehicle: Node, seat: Node3D, vehicle_camera: Camera3D) -> void:
|
||||
_in_vehicle = true
|
||||
freeze = true
|
||||
sleeping = true
|
||||
collision_layer = 0
|
||||
collision_mask = 0
|
||||
_vehicle_original_parent = get_parent()
|
||||
_light_was_on = _flashlight.visible
|
||||
_flashlight.visible = false
|
||||
if _model_root:
|
||||
_model_root.visible = false
|
||||
if seat:
|
||||
reparent(seat, true)
|
||||
global_transform = seat.global_transform
|
||||
if cam:
|
||||
cam.current = false
|
||||
if vehicle_camera:
|
||||
vehicle_camera.current = true
|
||||
vehicle_entered.emit(_vehicle)
|
||||
|
||||
func exit_vehicle(exit_point: Node3D, vehicle_camera: Camera3D) -> void:
|
||||
_in_vehicle = false
|
||||
freeze = false
|
||||
sleeping = false
|
||||
collision_layer = _vehicle_collision_layer
|
||||
collision_mask = _vehicle_collision_mask
|
||||
if _vehicle_original_parent:
|
||||
reparent(_vehicle_original_parent, true)
|
||||
_vehicle_original_parent = null
|
||||
_flashlight.visible = _light_was_on
|
||||
if _model_root:
|
||||
_model_root.visible = true
|
||||
if exit_point:
|
||||
global_transform = exit_point.global_transform
|
||||
if vehicle_camera:
|
||||
vehicle_camera.current = false
|
||||
if cam:
|
||||
cam.current = true
|
||||
vehicle_exited.emit(null)
|
||||
|
||||
|
||||
func is_in_vehicle() -> bool:
|
||||
return _in_vehicle
|
||||
|
||||
|
||||
func get_look_origin() -> Vector3:
|
||||
return global_position + Vector3.UP * FIREBALL_SPAWN_UP_OFFSET
|
||||
|
||||
|
||||
func get_look_direction() -> Vector3:
|
||||
if cam:
|
||||
return (-cam.global_transform.basis.z).normalized()
|
||||
return (-global_basis.z).normalized()
|
||||
@@ -1 +0,0 @@
|
||||
uid://kgqaeqappow3
|
||||
@@ -1,6 +1,6 @@
|
||||
[gd_scene load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/Characters/location_player.gd" id="1_player_script"]
|
||||
[ext_resource type="Script" path="res://scenes/player.gd" id="1_player_script"]
|
||||
[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="2_model"]
|
||||
|
||||
[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
|
||||
|
||||
@@ -6,11 +6,20 @@ extends Node3D
|
||||
@export var max_mushrooms_in_radius := 3
|
||||
@export var check_radius := 1.5
|
||||
@export var max_total_mushrooms := 50
|
||||
@export var interact_radius := 2.0
|
||||
@export var look_at_threshold := 0.95
|
||||
|
||||
static var total_mushrooms := 0
|
||||
|
||||
@onready var spread_timer: Timer = $SpreadTimer
|
||||
@onready var ground_ray: RayCast3D = $GroundRay
|
||||
@onready var cap: MeshInstance3D = $Cap
|
||||
@onready var stem: MeshInstance3D = $Stem
|
||||
|
||||
var _player_in_range := false
|
||||
var _is_highlighted := false
|
||||
var highlight_mat: StandardMaterial3D
|
||||
var _audio_player: AudioStreamPlayer3D
|
||||
|
||||
func _ready() -> void:
|
||||
total_mushrooms += 1
|
||||
@@ -20,6 +29,111 @@ func _ready() -> void:
|
||||
scale = Vector3.ONE * randf_range(0.8, 1.2)
|
||||
rotation.y = randf_range(0, TAU)
|
||||
|
||||
# Setup interaction area
|
||||
var area = Area3D.new()
|
||||
add_child(area)
|
||||
var shape = CollisionShape3D.new()
|
||||
var sphere = SphereShape3D.new()
|
||||
sphere.radius = interact_radius
|
||||
shape.shape = sphere
|
||||
area.add_child(shape)
|
||||
|
||||
area.body_entered.connect(_on_body_entered)
|
||||
area.body_exited.connect(_on_body_exited)
|
||||
|
||||
highlight_mat = StandardMaterial3D.new()
|
||||
highlight_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
highlight_mat.albedo_color = Color(1.0, 1.0, 1.0, 0.3)
|
||||
highlight_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
|
||||
_audio_player = AudioStreamPlayer3D.new()
|
||||
_audio_player.stream = preload("res://assets/audio/pop.ogg")
|
||||
add_child(_audio_player)
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _player_in_range:
|
||||
_set_highlight(false)
|
||||
return
|
||||
|
||||
var cam = get_viewport().get_camera_3d()
|
||||
if not cam:
|
||||
_set_highlight(false)
|
||||
return
|
||||
|
||||
var dir_to_mushroom = (global_position - cam.global_position).normalized()
|
||||
var forward = -cam.global_transform.basis.z
|
||||
var my_dot = forward.dot(dir_to_mushroom)
|
||||
|
||||
if my_dot > look_at_threshold:
|
||||
# Check if we are the most directly looked-at mushroom in range
|
||||
var is_best = true
|
||||
for mushroom in get_tree().get_nodes_in_group("mushrooms"):
|
||||
if mushroom == self or not mushroom.get("_player_in_range"):
|
||||
continue
|
||||
|
||||
var m_dir = (mushroom.global_position - cam.global_position).normalized()
|
||||
var m_dot = forward.dot(m_dir)
|
||||
|
||||
if m_dot > my_dot:
|
||||
is_best = false
|
||||
break
|
||||
elif m_dot == my_dot and mushroom.get_instance_id() > get_instance_id():
|
||||
# Tie breaker
|
||||
is_best = false
|
||||
break
|
||||
|
||||
_set_highlight(is_best)
|
||||
else:
|
||||
_set_highlight(false)
|
||||
|
||||
func _set_highlight(value: bool) -> void:
|
||||
if _is_highlighted == value: return
|
||||
_is_highlighted = value
|
||||
|
||||
if value:
|
||||
if cap: cap.material_overlay = highlight_mat
|
||||
if stem: stem.material_overlay = highlight_mat
|
||||
else:
|
||||
if cap: cap.material_overlay = null
|
||||
if stem: stem.material_overlay = null
|
||||
|
||||
func _on_body_entered(body: Node3D) -> void:
|
||||
if body.is_in_group("player"):
|
||||
_player_in_range = true
|
||||
|
||||
func _on_body_exited(body: Node3D) -> void:
|
||||
if body.is_in_group("player"):
|
||||
_player_in_range = false
|
||||
_set_highlight(false)
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _is_highlighted and event.is_action_pressed("interact"):
|
||||
get_viewport().set_input_as_handled()
|
||||
_collect_mushroom()
|
||||
|
||||
func _collect_mushroom() -> void:
|
||||
# Disable interaction and visuals
|
||||
_is_highlighted = false
|
||||
_set_highlight(false)
|
||||
_player_in_range = false
|
||||
|
||||
if cap: cap.visible = false
|
||||
if stem: stem.visible = false
|
||||
|
||||
# Stop spreading logic
|
||||
spread_timer.stop()
|
||||
|
||||
# Disable processing so it can't be interacted with again
|
||||
set_process_unhandled_input(false)
|
||||
set_process(false)
|
||||
|
||||
# Play sound
|
||||
_audio_player.play()
|
||||
|
||||
# Wait for sound to finish, then free
|
||||
await _audio_player.finished
|
||||
queue_free()
|
||||
|
||||
func _setup_timer() -> void:
|
||||
spread_timer.wait_time = randf_range(spread_interval_min, spread_interval_max)
|
||||
spread_timer.start()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
extends Node
|
||||
|
||||
var _requested_spawn_name: StringName = &""
|
||||
var camera_zoom_dist: float = 3.0
|
||||
var third_person_camera_zoom_dist: float = 3.0
|
||||
var camera_is_first_person: bool = false
|
||||
|
||||
|
||||
func request_spawn(spawn_name: StringName) -> void:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[gd_scene load_steps=15 format=3 uid="uid://b7p7k1i4t0m2l"]
|
||||
[gd_scene format=3 uid="uid://b7p7k1i4t0m2l"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/Levels/transportation_level.gd" id="1_6y4q1"]
|
||||
[ext_resource type="Script" path="res://scenes/player.gd" id="2_player"]
|
||||
[ext_resource type="PackedScene" path="res://assets/models/TestCharAnimated.glb" id="3_model"]
|
||||
[ext_resource type="Script" uid="uid://c2vm651r4nepy" path="res://scenes/Levels/transportation_level.gd" id="1_6y4q1"]
|
||||
[ext_resource type="Script" uid="uid://bpxggc8nr6tf6" path="res://scenes/player.gd" id="2_player"]
|
||||
[ext_resource type="PackedScene" uid="uid://bnqaqbgynoyys" path="res://assets/models/TestCharAnimated.glb" id="3_model"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/Interaction/prototype_gateway.tscn" id="4_teleporter"]
|
||||
[ext_resource type="Material" path="res://assets/materials/kenney_prototype_ground_green.tres" id="5_ground_mat"]
|
||||
[ext_resource type="PackedScene" uid="uid://dp6jk0k3o4v1u" path="res://scenes/UI/pause_menu.tscn" id="6_pause_menu"]
|
||||
@@ -11,8 +11,6 @@
|
||||
[ext_resource type="Material" path="res://assets/materials/kenney_prototype_prop_red.tres" id="9_checkpoint_mat"]
|
||||
[ext_resource type="PackedScene" uid="uid://bnwpu7p8sbsfa" path="res://scenes/Interaction/RadialCommandMenu.tscn" id="10_radial_menu"]
|
||||
|
||||
[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
|
||||
size = Vector3(1080, 2, 1080)
|
||||
|
||||
@@ -20,6 +18,8 @@ size = Vector3(1080, 2, 1080)
|
||||
material = ExtResource("5_ground_mat")
|
||||
size = Vector3(1080, 2, 1080)
|
||||
|
||||
[sub_resource type="SphereShape3D" id="SphereShape3D_player"]
|
||||
|
||||
[sub_resource type="SphereShape3D" id="SphereShape3D_checkpoint"]
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_checkpoint"]
|
||||
@@ -27,83 +27,83 @@ material = ExtResource("9_checkpoint_mat")
|
||||
radius = 1.2
|
||||
height = 2.4
|
||||
|
||||
[node name="TransportationLevel" type="Node3D"]
|
||||
[node name="TransportationLevel" type="Node3D" unique_id=81192309]
|
||||
script = ExtResource("1_6y4q1")
|
||||
|
||||
[node name="Ground" type="StaticBody3D" parent="."]
|
||||
[node name="Ground" type="StaticBody3D" parent="." unique_id=1298491765]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1, 0)
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground" unique_id=626099188]
|
||||
shape = SubResource("BoxShape3D_ground")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground" unique_id=1132270788]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00053596497, 0.0075991154, -0.0019865036)
|
||||
mesh = SubResource("BoxMesh_ground")
|
||||
|
||||
[node name="Player" type="RigidBody3D" parent="."]
|
||||
[node name="Player" type="RigidBody3D" parent="." unique_id=1788131730]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 0)
|
||||
script = ExtResource("2_player")
|
||||
camera_path = NodePath("Camera3D")
|
||||
phone_path = NodePath("../PhoneUI")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player"]
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Player" unique_id=1895789906]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
|
||||
shape = SubResource("SphereShape3D_player")
|
||||
|
||||
[node name="TestCharAnimated" parent="Player" instance=ExtResource("3_model")]
|
||||
[node name="TestCharAnimated" parent="Player" unique_id=557318212 instance=ExtResource("3_model")]
|
||||
transform = Transform3D(-0.9998549, 0, 0.01703362, 0, 1, 0, -0.01703362, 0, -0.9998549, 0, 0, 0)
|
||||
|
||||
[node name="Camera3D" type="Camera3D" parent="Player"]
|
||||
[node name="Camera3D" type="Camera3D" parent="Player" unique_id=1290959549]
|
||||
transform = Transform3D(0.9989785, -4.651856e-10, -0.045188628, 0.006969331, 0.9880354, 0.15407, 0.044647958, -0.15422754, 0.9870261, 0.22036135, 1.8988357, 0.64972365)
|
||||
current = true
|
||||
fov = 49.0
|
||||
|
||||
[node name="SpotLight3D" type="SpotLight3D" parent="Player"]
|
||||
[node name="SpotLight3D" type="SpotLight3D" parent="Player" unique_id=1804564926]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.906308, -0.422618, 0, 0.422618, 0.906308, 0, 1.7, -0.35)
|
||||
visible = false
|
||||
spot_range = 30.0
|
||||
spot_angle = 25.0
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="."]
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1364437059]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.819152, 0.573576, 0, -0.573576, 0.819152, 0, 6, 0)
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="EntrySpawn" type="Marker3D" parent="."]
|
||||
[node name="EntrySpawn" type="Marker3D" parent="." unique_id=1816177911]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2)
|
||||
|
||||
[node name="Car" parent="." instance=ExtResource("7_car")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0, -3)
|
||||
[node name="Car" parent="." unique_id=441668490 instance=ExtResource("7_car")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -6, 0.078942776, -5.401241)
|
||||
|
||||
[node name="QuestCheckpoint" type="Area3D" parent="."]
|
||||
[node name="QuestCheckpoint" type="Area3D" parent="." unique_id=1536655109]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 9, 0, -10)
|
||||
script = ExtResource("8_qtrigger")
|
||||
event_name = &"reach_checkpoint"
|
||||
target_group = &"vehicle"
|
||||
quest_id_filter = "first_drive"
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="QuestCheckpoint"]
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="QuestCheckpoint" unique_id=529997293]
|
||||
shape = SubResource("SphereShape3D_checkpoint")
|
||||
|
||||
[node name="Visual" type="MeshInstance3D" parent="QuestCheckpoint"]
|
||||
[node name="Visual" type="MeshInstance3D" parent="QuestCheckpoint" unique_id=1896410895]
|
||||
mesh = SubResource("SphereMesh_checkpoint")
|
||||
|
||||
[node name="ReturnTeleporter" parent="." instance=ExtResource("4_teleporter")]
|
||||
[node name="ReturnTeleporter" parent="." unique_id=1452006361 instance=ExtResource("4_teleporter")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5, 0, 0)
|
||||
target_scene_path = "res://scenes/Levels/level.tscn"
|
||||
target_spawn_name = &"CarReturnSpawn"
|
||||
|
||||
[node name="Label3D" type="Label3D" parent="ReturnTeleporter"]
|
||||
[node name="Label3D" type="Label3D" parent="ReturnTeleporter" unique_id=1075823264]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.965926, -0.258819, 0, 0.258819, 0.965926, 0, 5.4, 0)
|
||||
pixel_size = 0.012
|
||||
text = "PLAYGROUND"
|
||||
|
||||
[node name="PauseMenu" parent="." instance=ExtResource("6_pause_menu")]
|
||||
[node name="PauseMenu" parent="." unique_id=471208565 instance=ExtResource("6_pause_menu")]
|
||||
|
||||
[node name="PhoneUI" type="CanvasLayer" parent="."]
|
||||
[node name="PhoneUI" type="CanvasLayer" parent="." unique_id=201860923]
|
||||
layer = 5
|
||||
visible = false
|
||||
|
||||
[node name="Control" type="Control" parent="PhoneUI"]
|
||||
[node name="Control" type="Control" parent="PhoneUI" unique_id=1674232100]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -111,7 +111,7 @@ anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control"]
|
||||
[node name="PhoneFrame" type="ColorRect" parent="PhoneUI/Control" unique_id=1215420900]
|
||||
layout_mode = 1
|
||||
anchors_preset = 8
|
||||
anchor_left = 0.5
|
||||
@@ -126,7 +126,7 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
color = Color(0.08, 0.08, 0.1, 1)
|
||||
|
||||
[node name="QuestTitle" type="Label" parent="PhoneUI/Control/PhoneFrame"]
|
||||
[node name="QuestTitle" type="Label" parent="PhoneUI/Control/PhoneFrame" unique_id=1337344808]
|
||||
layout_mode = 0
|
||||
offset_left = 18.0
|
||||
offset_top = 18.0
|
||||
@@ -134,7 +134,7 @@ offset_right = 150.0
|
||||
offset_bottom = 41.0
|
||||
text = "Quest Log"
|
||||
|
||||
[node name="QuestText" type="RichTextLabel" parent="PhoneUI/Control/PhoneFrame"]
|
||||
[node name="QuestText" type="RichTextLabel" parent="PhoneUI/Control/PhoneFrame" unique_id=188206396]
|
||||
layout_mode = 0
|
||||
offset_left = 18.0
|
||||
offset_top = 52.0
|
||||
@@ -144,5 +144,4 @@ bbcode_enabled = true
|
||||
text = "No active quest."
|
||||
scroll_active = false
|
||||
|
||||
[node name="RadialCommandMenu" parent="PhoneUI/Control" instance=ExtResource("10_radial_menu")]
|
||||
layout_mode = 1
|
||||
[node name="RadialCommandMenu" parent="PhoneUI/Control" unique_id=67282635 instance=ExtResource("10_radial_menu")]
|
||||
|
||||
@@ -3,7 +3,7 @@ extends RigidBody3D
|
||||
@export var drive_speed := 18.0
|
||||
@export var drive_accel := 20.0
|
||||
@export var brake_strength := 28.0
|
||||
@export var turn_speed := 2.0
|
||||
@export var turn_speed := 4.0
|
||||
@export var turn_accel := 8.0
|
||||
@export var lateral_damp := 10.0
|
||||
@export var launch_impulse := 28.0
|
||||
@@ -12,14 +12,28 @@ extends RigidBody3D
|
||||
@export var exit_path: NodePath
|
||||
@export var camera_path: NodePath
|
||||
@export var interact_area_path: NodePath
|
||||
@export var engine_audio_fade_speed: float = 8.0
|
||||
@export var hit_sound_cooldown: float = 0.25
|
||||
|
||||
@onready var seat: Node3D = get_node(seat_path) if seat_path != NodePath("") else null
|
||||
@onready var exit_point: Node3D = get_node(exit_path) if exit_path != NodePath("") else null
|
||||
@onready var car_camera: Camera3D = get_node(camera_path) if camera_path != NodePath("") else null
|
||||
@onready var interact_area: Area3D = get_node(interact_area_path) if interact_area_path != NodePath("") else null
|
||||
@onready var engine_idle_audio: AudioStreamPlayer3D = get_node_or_null("Audio/EngineIdle")
|
||||
@onready var engine_drive_audio: AudioStreamPlayer3D = get_node_or_null("Audio/EngineDrive")
|
||||
@onready var engine_accel_audio: AudioStreamPlayer3D = get_node_or_null("Audio/EngineAccel")
|
||||
@onready var enter_audio: AudioStreamPlayer3D = get_node_or_null("Audio/Enter")
|
||||
@onready var exit_audio: AudioStreamPlayer3D = get_node_or_null("Audio/Exit")
|
||||
@onready var brake_audio: AudioStreamPlayer3D = get_node_or_null("Audio/Brake")
|
||||
@onready var hit_audio: AudioStreamPlayer3D = get_node_or_null("Audio/Hit")
|
||||
|
||||
var _nearby_driver: Node = null
|
||||
var _driver: Node = null
|
||||
var _speed_factor: float = 0.0
|
||||
var _throttle_input: float = 0.0
|
||||
var _braking: bool = false
|
||||
var _was_braking: bool = false
|
||||
var _hit_sound_time_left: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("vehicle")
|
||||
@@ -33,17 +47,23 @@ func _ready() -> void:
|
||||
contact_monitor = true
|
||||
max_contacts_reported = 8
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
func _process(delta: float) -> void:
|
||||
if _driver == null and _nearby_driver != null and Input.is_action_just_pressed("interact"):
|
||||
_enter_vehicle(_nearby_driver)
|
||||
elif _driver != null and Input.is_action_just_pressed("interact"):
|
||||
_exit_vehicle()
|
||||
_update_audio(delta)
|
||||
_hit_sound_time_left = maxf(0.0, _hit_sound_time_left - delta)
|
||||
|
||||
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||
if _driver == null:
|
||||
_speed_factor = 0.0
|
||||
_throttle_input = 0.0
|
||||
_braking = false
|
||||
return
|
||||
|
||||
var input2v := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
|
||||
_throttle_input = input2v.y
|
||||
var forward := global_transform.basis.z
|
||||
forward.y = 0.0
|
||||
forward = forward.normalized()
|
||||
@@ -51,6 +71,9 @@ func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||
var current_speed := linear_velocity.dot(forward)
|
||||
var target_speed := input2v.y * drive_speed
|
||||
var accel := drive_accel if abs(input2v.y) > 0.01 else brake_strength
|
||||
var is_coasting_brake: bool = abs(input2v.y) <= 0.01 and abs(current_speed) > drive_speed * 0.35
|
||||
var is_reverse_brake: bool = abs(input2v.y) > 0.01 and signf(input2v.y) != signf(current_speed) and abs(current_speed) > drive_speed * 0.25
|
||||
_braking = is_coasting_brake or is_reverse_brake
|
||||
var new_forward_speed := move_toward(current_speed, target_speed, accel * state.step)
|
||||
var forward_vel := forward * new_forward_speed
|
||||
var lateral_vel := linear_velocity - (forward * current_speed)
|
||||
@@ -58,6 +81,7 @@ func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||
linear_velocity = forward_vel + lateral_vel
|
||||
|
||||
var speed_factor: float = clamp(abs(new_forward_speed) / drive_speed, 0.0, 1.0)
|
||||
_speed_factor = speed_factor
|
||||
var turn_input := input2v.x
|
||||
if new_forward_speed < -0.1:
|
||||
turn_input = -turn_input
|
||||
@@ -80,12 +104,15 @@ func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||
launch_dir = normal_world if normal_world.length() > 0.001 else forward.normalized()
|
||||
var impulse := launch_dir * (launch_impulse * speed_factor) + Vector3.UP * launch_up_impulse
|
||||
(collider as RigidBody3D).apply_central_impulse(impulse)
|
||||
_play_hit_audio(speed_factor)
|
||||
|
||||
func _enter_vehicle(player: Node) -> void:
|
||||
if seat == null:
|
||||
return
|
||||
_driver = player
|
||||
player.call("enter_vehicle", self, seat, car_camera)
|
||||
_play_one_shot(enter_audio)
|
||||
_start_engine_audio()
|
||||
if car_camera:
|
||||
car_camera.current = true
|
||||
|
||||
@@ -94,6 +121,10 @@ func _exit_vehicle() -> void:
|
||||
return
|
||||
_driver.call("exit_vehicle", exit_point, car_camera)
|
||||
_driver = null
|
||||
_speed_factor = 0.0
|
||||
_throttle_input = 0.0
|
||||
_braking = false
|
||||
_play_one_shot(exit_audio)
|
||||
if car_camera:
|
||||
car_camera.current = false
|
||||
|
||||
@@ -104,3 +135,55 @@ func _on_interact_body_entered(body: Node) -> void:
|
||||
func _on_interact_body_exited(body: Node) -> void:
|
||||
if body == _nearby_driver:
|
||||
_nearby_driver = null
|
||||
|
||||
func _start_engine_audio() -> void:
|
||||
_start_engine_player(engine_idle_audio)
|
||||
_start_engine_player(engine_drive_audio)
|
||||
_start_engine_player(engine_accel_audio)
|
||||
|
||||
func _update_audio(delta: float) -> void:
|
||||
if _driver != null:
|
||||
_start_engine_audio()
|
||||
|
||||
var active: bool = _driver != null
|
||||
var throttle_weight: float = clamp(abs(_throttle_input), 0.0, 1.0)
|
||||
var accel_weight: float = throttle_weight * clamp(_speed_factor * 1.25, 0.0, 1.0)
|
||||
var drive_weight: float = clamp(_speed_factor * 1.25, 0.0, 1.0) * (1.0 - accel_weight * 0.45)
|
||||
var idle_weight: float = 0.9 - clamp(_speed_factor * 1.35, 0.0, 0.72)
|
||||
|
||||
_set_engine_player(engine_idle_audio, idle_weight if active else 0.0, 0.86 + _speed_factor * 0.18, delta)
|
||||
_set_engine_player(engine_drive_audio, drive_weight if active else 0.0, 0.92 + _speed_factor * 0.35, delta)
|
||||
_set_engine_player(engine_accel_audio, accel_weight if active else 0.0, 0.96 + _speed_factor * 0.5, delta)
|
||||
|
||||
if _braking and not _was_braking:
|
||||
_play_one_shot(brake_audio)
|
||||
_was_braking = _braking
|
||||
|
||||
func _set_engine_player(player: AudioStreamPlayer3D, weight: float, pitch: float, delta: float) -> void:
|
||||
if player == null:
|
||||
return
|
||||
var target_volume: float = linear_to_db(maxf(weight, 0.0001)) - 2.0
|
||||
if weight <= 0.001:
|
||||
target_volume = -80.0
|
||||
player.volume_db = move_toward(player.volume_db, target_volume, engine_audio_fade_speed * 10.0 * delta)
|
||||
player.pitch_scale = move_toward(player.pitch_scale, pitch, engine_audio_fade_speed * delta)
|
||||
|
||||
func _start_engine_player(player: AudioStreamPlayer3D) -> void:
|
||||
if player == null or player.playing:
|
||||
return
|
||||
player.play()
|
||||
|
||||
func _play_one_shot(player: AudioStreamPlayer3D) -> void:
|
||||
if player == null:
|
||||
return
|
||||
player.stop()
|
||||
player.play()
|
||||
|
||||
func _play_hit_audio(speed_factor: float) -> void:
|
||||
if hit_audio == null or _hit_sound_time_left > 0.0:
|
||||
return
|
||||
hit_audio.pitch_scale = randf_range(0.92, 1.08)
|
||||
var hit_volume: float = clamp(speed_factor, 0.25, 1.0)
|
||||
hit_audio.volume_db = linear_to_db(hit_volume) - 3.0
|
||||
hit_audio.play()
|
||||
_hit_sound_time_left = hit_sound_cooldown
|
||||
|
||||
@@ -1,41 +1,131 @@
|
||||
[gd_scene load_steps=6 format=3]
|
||||
[gd_scene format=3 uid="uid://uj3g2h3yfv4f"]
|
||||
|
||||
[ext_resource type="Script" path="res://scenes/Vehicles/car.gd" id="1_kbd20"]
|
||||
[ext_resource type="Material" path="res://assets/materials/kenney_prototype_prop_red.tres" id="2_car_mat"]
|
||||
[ext_resource type="Script" uid="uid://4qf5yinepytc" path="res://scenes/Vehicles/car.gd" id="1_kbd20"]
|
||||
[ext_resource type="PackedScene" uid="uid://dvgiooankbnw3" path="res://assets/models/vehicles/doge/doge-body.glb" id="2_doge_body"]
|
||||
[ext_resource type="PackedScene" uid="uid://civr7hlbf2kqb" path="res://assets/models/vehicles/doge/Wheel.glb" id="3_doge_wheel"]
|
||||
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_engine_idle_loop.wav" id="4_engine_idle"]
|
||||
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_engine_drive_loop.wav" id="5_engine_drive"]
|
||||
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_engine_accel_loop.wav" id="6_engine_accel"]
|
||||
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_enter.wav" id="7_enter"]
|
||||
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_exit.wav" id="8_exit"]
|
||||
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_brake.wav" id="9_brake"]
|
||||
[ext_resource type="AudioStream" path="res://assets/audio/vehicles/car/car_hit.wav" id="10_hit"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_7r1j6"]
|
||||
size = Vector3(1.4, 0.9, 2.6)
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_body_center"]
|
||||
size = Vector3(1.9717, 1, 2.08592)
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_jk0m1"]
|
||||
size = Vector3(2.2, 2.0, 3.8)
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body_rear"]
|
||||
radius = 0.291194
|
||||
|
||||
[sub_resource type="BoxMesh" id="BoxMesh_4y8xk"]
|
||||
size = Vector3(1.4, 0.9, 2.6)
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_body_mid"]
|
||||
size = Vector3(0.542114, 1.7092, 1.53265)
|
||||
|
||||
[node name="Car" type="RigidBody3D"]
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body_front"]
|
||||
radius = 0.363529
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_interact"]
|
||||
size = Vector3(3.2, 2.4, 6)
|
||||
|
||||
[node name="Car" type="RigidBody3D" unique_id=347281788]
|
||||
script = ExtResource("1_kbd20")
|
||||
seat_path = NodePath("Seat")
|
||||
exit_path = NodePath("Exit")
|
||||
camera_path = NodePath("CarCamera")
|
||||
interact_area_path = NodePath("InteractArea")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("BoxShape3D_7r1j6")
|
||||
[node name="CollisionCenter" type="CollisionShape3D" parent="." unique_id=1615707894]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.45, -0.062)
|
||||
shape = SubResource("BoxShape3D_body_center")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("BoxMesh_4y8xk")
|
||||
surface_material_override/0 = ExtResource("2_car_mat")
|
||||
[node name="CollisionRear" type="CollisionShape3D" parent="." unique_id=1572737985]
|
||||
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, 0, 0.45, -2.08)
|
||||
shape = SubResource("CapsuleShape3D_body_rear")
|
||||
|
||||
[node name="InteractArea" type="Area3D" parent="."]
|
||||
[node name="CollisionMid" type="CollisionShape3D" parent="." unique_id=1115594476]
|
||||
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, -0.001, 0.45, -1.11)
|
||||
shape = SubResource("BoxShape3D_body_mid")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="InteractArea"]
|
||||
shape = SubResource("BoxShape3D_jk0m1")
|
||||
[node name="CollisionFront" type="CollisionShape3D" parent="." unique_id=601157615]
|
||||
transform = Transform3D(-4.37114e-08, -1, 0, 1, -4.37114e-08, 0, 0, 0, 1, -0.001, 0.45, 1.998)
|
||||
shape = SubResource("CapsuleShape3D_body_front")
|
||||
|
||||
[node name="Seat" type="Node3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0, 0.6, 0.0)
|
||||
[node name="DogeBody" parent="." unique_id=1839362199 instance=ExtResource("2_doge_body")]
|
||||
transform = Transform3D(-1, 0, -8.74228e-08, 0, 1, 0, 8.74228e-08, 0, -1, 0, 0, 0)
|
||||
|
||||
[node name="Exit" type="Node3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3, 0.0, 0.0)
|
||||
[node name="WheelFrontRight" parent="." unique_id=2135146556 instance=ExtResource("3_doge_wheel")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.854765, 0.340338, -1.50006)
|
||||
|
||||
[node name="CarCamera" type="Camera3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.0, 2.0, 4.2)
|
||||
[node name="WheelFrontLeft" parent="." unique_id=595413685 instance=ExtResource("3_doge_wheel")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.949166, 0.340338, -1.50006)
|
||||
|
||||
[node name="WheelRearRight" parent="." unique_id=521166039 instance=ExtResource("3_doge_wheel")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.854765, 0.286814, 1.26411)
|
||||
|
||||
[node name="WheelRearLeft" parent="." unique_id=859253276 instance=ExtResource("3_doge_wheel")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.949166, 0.286814, 1.26411)
|
||||
|
||||
[node name="InteractArea" type="Area3D" parent="." unique_id=1982997129]
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="InteractArea" unique_id=2089774080]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.8, 0)
|
||||
shape = SubResource("BoxShape3D_interact")
|
||||
|
||||
[node name="Seat" type="Node3D" parent="." unique_id=1298631680]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
|
||||
|
||||
[node name="Exit" type="Node3D" parent="." unique_id=1734274203]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3, 0, 0)
|
||||
|
||||
[node name="CarCamera" type="Camera3D" parent="." unique_id=1783366397]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2, 4.2)
|
||||
|
||||
[node name="Audio" type="Node3D" parent="."]
|
||||
|
||||
[node name="EngineIdle" type="AudioStreamPlayer3D" parent="Audio"]
|
||||
stream = ExtResource("4_engine_idle")
|
||||
volume_db = -80.0
|
||||
bus = &"SFX"
|
||||
unit_size = 6.0
|
||||
max_distance = 45.0
|
||||
|
||||
[node name="EngineDrive" type="AudioStreamPlayer3D" parent="Audio"]
|
||||
stream = ExtResource("5_engine_drive")
|
||||
volume_db = -80.0
|
||||
bus = &"SFX"
|
||||
unit_size = 6.0
|
||||
max_distance = 55.0
|
||||
|
||||
[node name="EngineAccel" type="AudioStreamPlayer3D" parent="Audio"]
|
||||
stream = ExtResource("6_engine_accel")
|
||||
volume_db = -80.0
|
||||
bus = &"SFX"
|
||||
unit_size = 6.0
|
||||
max_distance = 60.0
|
||||
|
||||
[node name="Enter" type="AudioStreamPlayer3D" parent="Audio"]
|
||||
stream = ExtResource("7_enter")
|
||||
volume_db = -7.0
|
||||
bus = &"SFX"
|
||||
unit_size = 5.0
|
||||
max_distance = 25.0
|
||||
|
||||
[node name="Exit" type="AudioStreamPlayer3D" parent="Audio"]
|
||||
stream = ExtResource("8_exit")
|
||||
volume_db = -7.0
|
||||
bus = &"SFX"
|
||||
unit_size = 5.0
|
||||
max_distance = 25.0
|
||||
|
||||
[node name="Brake" type="AudioStreamPlayer3D" parent="Audio"]
|
||||
stream = ExtResource("9_brake")
|
||||
volume_db = -5.0
|
||||
bus = &"SFX"
|
||||
unit_size = 5.0
|
||||
max_distance = 35.0
|
||||
|
||||
[node name="Hit" type="AudioStreamPlayer3D" parent="Audio"]
|
||||
stream = ExtResource("10_hit")
|
||||
volume_db = -6.0
|
||||
bus = &"SFX"
|
||||
unit_size = 7.0
|
||||
max_distance = 45.0
|
||||
|
||||
@@ -11,9 +11,15 @@ const ACCELLERATION := 30.0
|
||||
const DECELLERATION := 40.0
|
||||
const JUMP_SPEED := 4.0
|
||||
const MAX_NUMBER_OF_JUMPS := 2
|
||||
const MIN_FOV := 10.0
|
||||
const MAX_FOV := 179.0
|
||||
const ZOOM_FACTOR := 1.1 # Zoom out when >1, in when < 1
|
||||
const DEFAULT_ZOOM_DIST := 3.0
|
||||
const MIN_ZOOM_DIST := 1.5
|
||||
const MAX_ZOOM_DIST := 8.0
|
||||
const ZOOM_STEP := 0.5
|
||||
const FIRST_PERSON_HEIGHT := 1.6
|
||||
const MODEL_SHOW_CAMERA_DISTANCE := 0.75
|
||||
var _zoom_dist := DEFAULT_ZOOM_DIST
|
||||
var _third_person_zoom_dist := DEFAULT_ZOOM_DIST
|
||||
var _is_first_person := false
|
||||
const FIREBALL_SPAWN_FORWARD_OFFSET := 1.5
|
||||
const FIREBALL_SPAWN_UP_OFFSET := 1.2
|
||||
var mouse_sensitivity := 0.005
|
||||
@@ -68,10 +74,18 @@ func _ready() -> void:
|
||||
audio_player.stream = jump_sound
|
||||
audio_player.volume_db = -20
|
||||
if cam:
|
||||
_camera_offset_local = cam.transform.origin
|
||||
if TeleportState != null:
|
||||
_third_person_zoom_dist = clampf(
|
||||
TeleportState.third_person_camera_zoom_dist,
|
||||
MIN_ZOOM_DIST,
|
||||
MAX_ZOOM_DIST
|
||||
)
|
||||
_is_first_person = TeleportState.camera_is_first_person or TeleportState.camera_zoom_dist == 0.0
|
||||
_zoom_dist = 0.0 if _is_first_person else _third_person_zoom_dist
|
||||
_camera_pitch = cam.rotation.x
|
||||
_camera_yaw = global_transform.basis.get_euler().y
|
||||
cam.set_as_top_level(true)
|
||||
update_camera_offset()
|
||||
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
|
||||
@@ -156,6 +170,8 @@ func _integrate_forces(state):
|
||||
cam.global_position = cam.global_position.lerp(target_pos, camera_follow_speed * state.step)
|
||||
cam.global_rotation = Vector3(_camera_pitch, _camera_yaw, 0.0)
|
||||
|
||||
_update_model_visibility()
|
||||
|
||||
_update_animation(on_floor, state.linear_velocity)
|
||||
_jump_triggered = false
|
||||
|
||||
@@ -173,14 +189,18 @@ func _input(event):
|
||||
if _in_vehicle:
|
||||
return
|
||||
|
||||
if event.is_action_pressed("player_toggle_perspective"):
|
||||
set_first_person(not _is_first_person)
|
||||
return
|
||||
|
||||
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
_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
|
||||
zoom_camera(true) # Zoom in
|
||||
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
|
||||
zoom_camera(ZOOM_FACTOR) # Zoom out
|
||||
zoom_camera(false) # Zoom out
|
||||
|
||||
if event.is_action_pressed("player_fireball"):
|
||||
shoot_fireball()
|
||||
@@ -189,11 +209,64 @@ func _input(event):
|
||||
if event.is_action_pressed("player_light"):
|
||||
_flashlight.visible = !_flashlight.visible
|
||||
|
||||
func zoom_camera(factor):
|
||||
func zoom_camera(zoom_in: bool) -> void:
|
||||
if cam == null:
|
||||
return
|
||||
var new_fov = cam.fov * factor
|
||||
cam.fov = clamp(new_fov, MIN_FOV, MAX_FOV)
|
||||
if zoom_in:
|
||||
if _is_first_person:
|
||||
return
|
||||
elif _zoom_dist <= MIN_ZOOM_DIST:
|
||||
set_first_person(true)
|
||||
return
|
||||
else:
|
||||
_zoom_dist = max(MIN_ZOOM_DIST, _zoom_dist - ZOOM_STEP)
|
||||
else:
|
||||
if _is_first_person:
|
||||
set_first_person(false)
|
||||
return
|
||||
else:
|
||||
_zoom_dist = min(MAX_ZOOM_DIST, _zoom_dist + ZOOM_STEP)
|
||||
|
||||
_third_person_zoom_dist = _zoom_dist
|
||||
_persist_camera_state()
|
||||
update_camera_offset()
|
||||
|
||||
func set_first_person(enabled: bool) -> void:
|
||||
if enabled == _is_first_person:
|
||||
return
|
||||
if enabled and _zoom_dist > 0.0:
|
||||
_third_person_zoom_dist = _zoom_dist
|
||||
_is_first_person = enabled
|
||||
_zoom_dist = 0.0 if enabled else _third_person_zoom_dist
|
||||
_persist_camera_state()
|
||||
update_camera_offset()
|
||||
_update_model_visibility()
|
||||
|
||||
func _persist_camera_state() -> void:
|
||||
if TeleportState == null:
|
||||
return
|
||||
TeleportState.camera_is_first_person = _is_first_person
|
||||
TeleportState.camera_zoom_dist = _zoom_dist
|
||||
TeleportState.third_person_camera_zoom_dist = _third_person_zoom_dist
|
||||
|
||||
func update_camera_offset() -> void:
|
||||
if _is_first_person:
|
||||
_camera_offset_local = Vector3(0.0, FIRST_PERSON_HEIGHT, 0.0)
|
||||
else:
|
||||
var height_offset := 0.25 * _zoom_dist
|
||||
_camera_offset_local = Vector3(0.5, FIRST_PERSON_HEIGHT + height_offset, _zoom_dist)
|
||||
|
||||
func _update_model_visibility() -> void:
|
||||
if _model_root == null:
|
||||
return
|
||||
if _in_vehicle or _is_first_person:
|
||||
_model_root.visible = false
|
||||
return
|
||||
if cam == null:
|
||||
_model_root.visible = true
|
||||
return
|
||||
var eye_position := global_position + Vector3.UP * FIRST_PERSON_HEIGHT
|
||||
_model_root.visible = cam.global_position.distance_to(eye_position) >= MODEL_SHOW_CAMERA_DISTANCE
|
||||
|
||||
func shoot_fireball() -> void:
|
||||
var direction := get_look_direction()
|
||||
@@ -257,7 +330,7 @@ func exit_vehicle(exit_point: Node3D, vehicle_camera: Camera3D) -> void:
|
||||
_vehicle_original_parent = null
|
||||
_flashlight.visible = _light_was_on
|
||||
if _model_root:
|
||||
_model_root.visible = true
|
||||
_model_root.visible = not _is_first_person
|
||||
if exit_point:
|
||||
global_transform = exit_point.global_transform
|
||||
if vehicle_camera:
|
||||
@@ -284,7 +357,7 @@ func is_in_vehicle() -> bool:
|
||||
|
||||
|
||||
func get_look_origin() -> Vector3:
|
||||
return global_position + Vector3.UP * 1.2
|
||||
return global_position + Vector3.UP * FIREBALL_SPAWN_UP_OFFSET
|
||||
|
||||
|
||||
func get_look_direction() -> Vector3:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AuthApi\AuthApi.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using AuthApi.Models;
|
||||
using AuthApi.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace AuthApi.Tests;
|
||||
|
||||
public sealed class LegalPolicyTests
|
||||
{
|
||||
private static LegalPolicy Policy(string version = "2026-07-20") => new(
|
||||
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Legal:TermsVersion"] = version,
|
||||
["Legal:PrivacyVersion"] = version,
|
||||
["Legal:EffectiveUtc"] = "2026-07-20T00:00:00Z"
|
||||
}).Build());
|
||||
|
||||
[Fact]
|
||||
public void ExistingUserWithoutAcceptanceRequiresAgreement()
|
||||
{
|
||||
Assert.True(Policy().Status(new User()).RequiresLegalAcceptance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptRecordsVersionsTimestampAndSource()
|
||||
{
|
||||
var user = new User();
|
||||
var acceptedUtc = new DateTime(2026, 7, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Policy().Accept(user, "password_registration", acceptedUtc);
|
||||
|
||||
Assert.Equal("2026-07-20", user.TermsAcceptedVersion);
|
||||
Assert.Equal("2026-07-20", user.PrivacyAcceptedVersion);
|
||||
Assert.Equal(acceptedUtc, user.LegalAcceptedUtc);
|
||||
var acceptance = Assert.Single(user.LegalAcceptances);
|
||||
Assert.Equal("password_registration", acceptance.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptIsIdempotentForCurrentVersions()
|
||||
{
|
||||
var user = new User();
|
||||
var policy = Policy();
|
||||
var first = new DateTime(2026, 7, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
policy.Accept(user, "registration", first);
|
||||
|
||||
policy.Accept(user, "prompt", first.AddHours(1));
|
||||
|
||||
Assert.Single(user.LegalAcceptances);
|
||||
Assert.Equal(first, user.LegalAcceptedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewPolicyVersionRequiresAcceptanceAgain()
|
||||
{
|
||||
var user = new User();
|
||||
Policy().Accept(user, "registration");
|
||||
|
||||
Assert.True(Policy("2026-08-01").Status(user).RequiresLegalAcceptance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using AuthApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AuthApi.Tests;
|
||||
|
||||
public class SteamOpenIdServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildAuthenticationUrl_UsesSteamEndpointAndBindsReturnUrl()
|
||||
{
|
||||
const string callback = "https://pauth.ranaze.com/api/Auth/steam/callback?transactionId=abc-123";
|
||||
|
||||
var result = SteamOpenIdService.BuildAuthenticationUrl("https://pauth.ranaze.com", callback);
|
||||
|
||||
Assert.StartsWith(SteamOpenIdService.Endpoint + "?", result);
|
||||
Assert.Contains("openid.mode=checkid_setup", result);
|
||||
Assert.Contains(Uri.EscapeDataString(callback), result);
|
||||
Assert.Contains(Uri.EscapeDataString("https://pauth.ranaze.com"), result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://steamcommunity.com/openid/id/76561198000000000", "76561198000000000")]
|
||||
[InlineData("http://steamcommunity.com/openid/id/76561198000000000", "76561198000000000")]
|
||||
[InlineData("https://example.com/openid/id/76561198000000000", null)]
|
||||
[InlineData("https://steamcommunity.com/openid/id/not-a-steamid", null)]
|
||||
public void ExtractSteamId_OnlyAcceptsSteamClaimedIds(string claimedId, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SteamOpenIdService.ExtractSteamId(claimedId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n", true)]
|
||||
[InlineData("is_valid:false\n", false)]
|
||||
[InlineData("is_valid:true-ish\n", false)]
|
||||
public void IsValidAuthenticationResponse_RequiresExactValidLine(string response, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, SteamOpenIdService.IsValidAuthenticationResponse(response));
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,8 @@
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AuthApi.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,10 +2,13 @@ using AuthApi.Models;
|
||||
using AuthApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Bson;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net.Mail;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace AuthApi.Controllers;
|
||||
|
||||
@@ -16,40 +19,69 @@ public class AuthController : ControllerBase
|
||||
private readonly UserService _users;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly BlacklistService _blacklist;
|
||||
private readonly IHttpClientFactory _httpClients;
|
||||
private readonly JwtTokenService _tokens;
|
||||
private readonly LegalPolicy _legal;
|
||||
|
||||
public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist)
|
||||
public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist,
|
||||
IHttpClientFactory httpClients, JwtTokenService tokens, LegalPolicy legal)
|
||||
{
|
||||
_users = users; _cfg = cfg; _blacklist = blacklist;
|
||||
_users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; _tokens = tokens; _legal = legal;
|
||||
}
|
||||
|
||||
[HttpGet("legal")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Legal() => Ok(_legal.Status());
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
|
||||
return BadRequest("Username and password required");
|
||||
var username = req.Username?.Trim() ?? "";
|
||||
var password = req.Password ?? "";
|
||||
var email = req.Email?.Trim() ?? "";
|
||||
|
||||
if (await _users.GetByUsernameAsync(req.Username) != null)
|
||||
return BadRequest("User already exists");
|
||||
if (!req.AcceptLegal)
|
||||
return BadRequest("You must agree to the Terms of Service and acknowledge the Privacy Policy");
|
||||
|
||||
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);
|
||||
if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$"))
|
||||
return BadRequest("Username must be 3-24 characters using only letters, numbers, or underscores");
|
||||
if (password.Length < 8)
|
||||
return BadRequest("Password must be at least 8 characters");
|
||||
var emailDomain = email.Split('@').LastOrDefault() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(email) || !MailAddress.TryCreate(email, out _) || !emailDomain.Contains('.'))
|
||||
return BadRequest("A valid email address is required");
|
||||
|
||||
if (await _users.GetByUsernameAsync(username) != null)
|
||||
return Conflict("Username already exists");
|
||||
|
||||
var hash = BCrypt.Net.BCrypt.HashPassword(password);
|
||||
var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email, CreatedUtc = DateTime.UtcNow };
|
||||
_legal.Accept(user, "password_registration");
|
||||
try
|
||||
{
|
||||
await _users.CreateAsync(user);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Conflict("Username already exists");
|
||||
}
|
||||
return Ok("User created");
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
var user = await _users.GetByUsernameAsync(req.Username);
|
||||
if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
var candidates = await _users.GetByLoginCandidatesAsync(req.Username);
|
||||
var user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(req.Password, candidate.PasswordHash));
|
||||
if (user == null)
|
||||
return Unauthorized();
|
||||
|
||||
var (accessToken, jti, expUtc) = GenerateJwtToken(user);
|
||||
var (accessToken, jti, expUtc) = _tokens.Generate(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 });
|
||||
return Ok(SessionResponse(user, accessToken, expUtc, jti, user.RefreshToken));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
@@ -59,8 +91,8 @@ public class AuthController : ControllerBase
|
||||
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 });
|
||||
var (accessToken, _, expUtc) = _tokens.Generate(user);
|
||||
return Ok(SessionResponse(user, accessToken, expUtc));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
@@ -74,6 +106,92 @@ public class AuthController : ControllerBase
|
||||
return Ok("Logged out.");
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Me()
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
return user is null ? Unauthorized() : Ok(ProfileResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("legal/accept")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> AcceptLegal([FromBody] AcceptLegalRequest req)
|
||||
{
|
||||
if (!req.Accept) return BadRequest("Legal acceptance is required.");
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null) return Unauthorized();
|
||||
_legal.Accept(user, "existing_user_prompt");
|
||||
await _users.UpdateAsync(user);
|
||||
return Ok(_legal.Status(user));
|
||||
}
|
||||
|
||||
[HttpPatch("me")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileRequest req)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null) return Unauthorized();
|
||||
var displayName = req.DisplayName?.Trim();
|
||||
var email = req.Email?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrEmpty(displayName) && displayName.Length > 80)
|
||||
return BadRequest("Display name must be 80 characters or fewer.");
|
||||
if (!string.IsNullOrEmpty(email) && !ValidEmail(email))
|
||||
return BadRequest("A valid email address is required.");
|
||||
if (!string.IsNullOrEmpty(email))
|
||||
{
|
||||
var existing = await _users.GetByEmailAsync(email);
|
||||
if (existing is not null && existing.Id != user.Id) return Conflict("Email address is already in use.");
|
||||
}
|
||||
user.DisplayName = string.IsNullOrEmpty(displayName) ? null : displayName;
|
||||
user.Email = string.IsNullOrEmpty(email) ? null : email;
|
||||
user.CreatedUtc ??= CreationDate(user);
|
||||
await _users.UpdateAsync(user);
|
||||
return Ok(ProfileResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("me/change-password")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest req)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null) return Unauthorized();
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash)) return BadRequest("Current password is incorrect.");
|
||||
if (req.NewPassword.Length < 8) return BadRequest("Password must be at least 8 characters.");
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
|
||||
user.RefreshToken = null;
|
||||
user.RefreshTokenExpiry = null;
|
||||
await _users.UpdateAsync(user);
|
||||
await RevokeCurrentTokenAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("me")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> DeleteAccount([FromBody] DeleteAccountRequest req)
|
||||
{
|
||||
var user = await CurrentUserAsync();
|
||||
if (user is null) return Unauthorized();
|
||||
if (req.Confirmation != "DELETE") return BadRequest("Type DELETE to confirm permanent deletion.");
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return BadRequest("Password is incorrect.");
|
||||
|
||||
var baseUrl = _cfg["QuestionsApi:BaseUrl"]?.TrimEnd('/');
|
||||
var secret = _cfg["InternalPurge:Secret"];
|
||||
if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(secret))
|
||||
return StatusCode(503, "Account deletion is temporarily unavailable.");
|
||||
var client = _httpClients.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
using var purge = new HttpRequestMessage(HttpMethod.Delete, $"{baseUrl}/api/profile/internal/users/{Uri.EscapeDataString(user.Id)}");
|
||||
purge.Headers.Add("X-MedMind-Internal-Key", secret);
|
||||
using var response = await client.SendAsync(purge);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return StatusCode(503, "Study data could not be removed. The account was not deleted; please try again.");
|
||||
|
||||
await RevokeCurrentTokenAsync();
|
||||
await _users.DeleteAsync(user.Id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("role")]
|
||||
[Authorize(Roles = "SUPER")]
|
||||
public async Task<IActionResult> ChangeUserRole([FromBody] ChangeRoleRequest req)
|
||||
@@ -90,24 +208,51 @@ public class AuthController : ControllerBase
|
||||
[Authorize(Roles = "SUPER")]
|
||||
public async Task<IActionResult> GetAllUsers() => Ok(await _users.GetAllAsync());
|
||||
|
||||
private (string token, string jti, DateTime expUtc) GenerateJwtToken(User user)
|
||||
private async Task<User?> CurrentUserAsync()
|
||||
{
|
||||
var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]!);
|
||||
var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
|
||||
var audience = _cfg["Jwt:Audience"] ?? issuer;
|
||||
var id = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
return string.IsNullOrWhiteSpace(id) ? null : await _users.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
private static bool ValidEmail(string email)
|
||||
{
|
||||
var domain = email.Split('@').LastOrDefault() ?? "";
|
||||
return MailAddress.TryCreate(email, out _) && domain.Contains('.');
|
||||
}
|
||||
|
||||
var exp = DateTime.UtcNow.AddMinutes(15);
|
||||
var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds);
|
||||
return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp);
|
||||
private static DateTime CreationDate(User user)
|
||||
{
|
||||
return ObjectId.TryParse(user.Id, out var id) ? id.CreationTime : DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private object ProfileResponse(User user) => new
|
||||
{
|
||||
userId = user.Id,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Email,
|
||||
user.Role,
|
||||
createdUtc = user.CreatedUtc ?? CreationDate(user),
|
||||
legal = _legal.Status(user)
|
||||
};
|
||||
|
||||
private object SessionResponse(User user, string accessToken, DateTime expUtc,
|
||||
string? jti = null, string? refreshToken = null) => new
|
||||
{
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user.Username,
|
||||
user.Role,
|
||||
jti,
|
||||
exp = expUtc,
|
||||
legal = _legal.Status(user)
|
||||
};
|
||||
|
||||
private async Task RevokeCurrentTokenAsync()
|
||||
{
|
||||
var token = HttpContext.Request.Headers.Authorization.FirstOrDefault()?.Replace("Bearer ", "");
|
||||
if (string.IsNullOrWhiteSpace(token)) return;
|
||||
var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token);
|
||||
await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
using AuthApi.Models;
|
||||
using AuthApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using MongoDB.Driver;
|
||||
using System.Net.Mail;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AuthApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/Auth/steam")]
|
||||
public class SteamAuthController : ControllerBase
|
||||
{
|
||||
private readonly UserService _users;
|
||||
private readonly SteamAuthTransactionService _transactions;
|
||||
private readonly SteamOpenIdService _openId;
|
||||
private readonly JwtTokenService _tokens;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<SteamAuthController> _logger;
|
||||
private readonly LegalPolicy _legal;
|
||||
|
||||
public SteamAuthController(UserService users, SteamAuthTransactionService transactions,
|
||||
SteamOpenIdService openId, JwtTokenService tokens, IConfiguration cfg,
|
||||
ILogger<SteamAuthController> logger, LegalPolicy legal)
|
||||
{
|
||||
_users = users;
|
||||
_transactions = transactions;
|
||||
_openId = openId;
|
||||
_tokens = tokens;
|
||||
_cfg = cfg;
|
||||
_logger = logger;
|
||||
_legal = legal;
|
||||
}
|
||||
|
||||
[HttpPost("start")]
|
||||
[EnableRateLimiting("steam-start")]
|
||||
public async Task<IActionResult> Start()
|
||||
{
|
||||
var realm = ConfiguredRealm();
|
||||
var transactionId = Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
var lifetime = Math.Clamp(_cfg.GetValue("Steam:TransactionTtlMinutes", 5), 2, 15);
|
||||
var expiresUtc = DateTime.UtcNow.AddMinutes(lifetime);
|
||||
await _transactions.CreateAsync(new SteamAuthTransaction
|
||||
{
|
||||
Id = transactionId,
|
||||
CreatedUtc = DateTime.UtcNow,
|
||||
ExpiresUtc = expiresUtc
|
||||
});
|
||||
var returnUrl = ReturnUrl(transactionId);
|
||||
return Ok(new
|
||||
{
|
||||
transactionId,
|
||||
authUrl = SteamOpenIdService.BuildAuthenticationUrl(realm, returnUrl),
|
||||
expiresUtc
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("callback")]
|
||||
[EnableRateLimiting("steam-complete")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string transactionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var transaction = await ActiveTransactionAsync(transactionId);
|
||||
if (transaction is null || transaction.Status != "pending")
|
||||
return CallbackPage(false, "This Steam sign-in request has expired. Return to RanaZe and try again.");
|
||||
|
||||
var steamId = await _openId.VerifyCallbackAsync(Request.Query, ReturnUrl(transactionId), cancellationToken);
|
||||
if (steamId is null)
|
||||
{
|
||||
_logger.LogWarning("Steam OpenID verification failed");
|
||||
return CallbackPage(false, "Steam could not verify this sign-in. Return to RanaZe and try again.");
|
||||
}
|
||||
|
||||
var user = await _users.GetBySteamIdAsync(steamId);
|
||||
var status = user is null ? "requires_account" : "complete";
|
||||
var changed = await _transactions.MarkVerifiedAsync(transactionId, steamId, status, user?.Id);
|
||||
if (!changed)
|
||||
return CallbackPage(false, "This Steam sign-in request has already been used.");
|
||||
if (user is not null)
|
||||
{
|
||||
user.RefreshToken = NewRefreshToken();
|
||||
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
||||
await _users.UpdateAsync(user);
|
||||
}
|
||||
return CallbackPage(true, "Steam verified your identity. You can return to RanaZe.");
|
||||
}
|
||||
|
||||
[HttpGet("status/{transactionId}")]
|
||||
[EnableRateLimiting("steam-poll")]
|
||||
public async Task<IActionResult> Status(string transactionId)
|
||||
{
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
var transaction = await ActiveTransactionAsync(transactionId);
|
||||
if (transaction is null)
|
||||
return NotFound(new { message = "Steam sign-in expired." });
|
||||
if (transaction.Status != "complete")
|
||||
return Ok(new { status = transaction.Status });
|
||||
if (string.IsNullOrWhiteSpace(transaction.UserId))
|
||||
return Conflict(new { message = "Steam sign-in is incomplete." });
|
||||
|
||||
var user = await _users.GetByIdAsync(transaction.UserId);
|
||||
if (user is null || user.SteamId != transaction.SteamId)
|
||||
return Conflict(new { message = "Steam account link is no longer valid." });
|
||||
if (string.IsNullOrWhiteSpace(user.RefreshToken) || user.RefreshTokenExpiry <= DateTime.UtcNow)
|
||||
{
|
||||
user.RefreshToken = NewRefreshToken();
|
||||
user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7);
|
||||
await _users.UpdateAsync(user);
|
||||
}
|
||||
var (accessToken, jti, exp) = _tokens.Generate(user);
|
||||
return Ok(new
|
||||
{
|
||||
status = "complete",
|
||||
accessToken,
|
||||
refreshToken = user.RefreshToken,
|
||||
user.Username,
|
||||
user.Role,
|
||||
jti,
|
||||
exp,
|
||||
legal = _legal.Status(user)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("complete")]
|
||||
[EnableRateLimiting("steam-complete")]
|
||||
public async Task<IActionResult> Complete([FromBody] CompleteSteamRequest request)
|
||||
{
|
||||
var transaction = await ActiveTransactionAsync(request.TransactionId);
|
||||
if (transaction?.Status == "complete")
|
||||
return Ok(new { status = "complete" });
|
||||
if (transaction is null || transaction.Status != "requires_account" || string.IsNullOrWhiteSpace(transaction.SteamId))
|
||||
return BadRequest(new { message = "Steam sign-in is not ready or has expired." });
|
||||
|
||||
User? user;
|
||||
var createdUser = false;
|
||||
if (request.Action == "create")
|
||||
{
|
||||
if (!request.AcceptLegal)
|
||||
return BadRequest(new { message = "You must agree to the Terms of Service and acknowledge the Privacy Policy." });
|
||||
var validation = ValidateNewAccount(request);
|
||||
if (validation is not null)
|
||||
return BadRequest(new { message = validation });
|
||||
if (await _users.GetByUsernameAsync(request.Username.Trim()) is not null)
|
||||
return Conflict(new { message = "Username already exists." });
|
||||
user = new User
|
||||
{
|
||||
Username = request.Username.Trim(),
|
||||
Email = request.Email!.Trim().ToLowerInvariant(),
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||
Role = "USER",
|
||||
CreatedUtc = DateTime.UtcNow,
|
||||
SteamId = transaction.SteamId,
|
||||
RefreshToken = NewRefreshToken(),
|
||||
RefreshTokenExpiry = DateTime.UtcNow.AddDays(7)
|
||||
};
|
||||
_legal.Accept(user, "steam_registration");
|
||||
try
|
||||
{
|
||||
await _users.CreateAsync(user);
|
||||
createdUser = true;
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Conflict(new { message = "That username or Steam account is already connected." });
|
||||
}
|
||||
}
|
||||
else if (request.Action == "link")
|
||||
{
|
||||
var candidates = await _users.GetByLoginCandidatesAsync(request.Username);
|
||||
user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash));
|
||||
if (user is null)
|
||||
return Unauthorized(new { message = "The account credentials are incorrect." });
|
||||
if (!string.IsNullOrWhiteSpace(user.SteamId) && user.SteamId != transaction.SteamId)
|
||||
return Conflict(new { message = "That RanaZe account is already connected to another Steam account." });
|
||||
try
|
||||
{
|
||||
var linked = await _users.TryLinkSteamAsync(user.Id, transaction.SteamId,
|
||||
NewRefreshToken(), DateTime.UtcNow.AddDays(7));
|
||||
if (!linked)
|
||||
return Conflict(new { message = "That account could not be connected to Steam." });
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Conflict(new { message = "That Steam account is already connected." });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest(new { message = "Action must be create or link." });
|
||||
}
|
||||
|
||||
if (!await _transactions.MarkCompleteAsync(request.TransactionId, transaction.SteamId, user.Id))
|
||||
{
|
||||
if (createdUser)
|
||||
await _users.DeleteAsync(user.Id);
|
||||
return Conflict(new { message = "This Steam sign-in was already completed." });
|
||||
}
|
||||
return Ok(new { status = "complete" });
|
||||
}
|
||||
|
||||
private async Task<SteamAuthTransaction?> ActiveTransactionAsync(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || id.Length > 128)
|
||||
return null;
|
||||
var transaction = await _transactions.GetAsync(id);
|
||||
return transaction is not null && transaction.ExpiresUtc > DateTime.UtcNow ? transaction : null;
|
||||
}
|
||||
|
||||
private string ConfiguredRealm() =>
|
||||
(_cfg["Steam:Realm"] ?? "https://pauth.ranaze.com").TrimEnd('/');
|
||||
|
||||
private string ReturnUrl(string transactionId)
|
||||
{
|
||||
var callback = _cfg["Steam:ReturnUrl"] ?? $"{ConfiguredRealm()}/api/Auth/steam/callback";
|
||||
return $"{callback}?transactionId={Uri.EscapeDataString(transactionId)}";
|
||||
}
|
||||
|
||||
private ContentResult CallbackPage(bool success, string message)
|
||||
{
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers.ContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'";
|
||||
Response.Headers.XFrameOptions = "DENY";
|
||||
Response.Headers["Referrer-Policy"] = "no-referrer";
|
||||
var color = success ? "#22c55e" : "#ef4444";
|
||||
var html = $$"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<title>RanaZe Steam sign-in</title><style>body{font-family:system-ui;background:#081324;color:#f8fafc;display:grid;place-items:center;min-height:100vh;margin:0}.card{max-width:34rem;padding:2rem;border:1px solid #33415d;border-radius:8px}h1{color:{{color}};font-size:1.4rem}</style></head>
|
||||
<body><main class="card"><h1>{{(success ? "Steam sign-in complete" : "Steam sign-in failed")}}</h1><p>{{System.Net.WebUtility.HtmlEncode(message)}}</p></main>
|
||||
<script>if(window.opener){setTimeout(()=>window.close(),1200);}</script></body></html>
|
||||
""";
|
||||
return Content(html, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
private static string? ValidateNewAccount(CompleteSteamRequest request)
|
||||
{
|
||||
var username = request.Username.Trim();
|
||||
var email = request.Email?.Trim() ?? "";
|
||||
if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$"))
|
||||
return "Username must be 3-24 characters using only letters, numbers, or underscores.";
|
||||
if (request.Password.Length < 8)
|
||||
return "Password must be at least 8 characters.";
|
||||
var domain = email.Split('@').LastOrDefault() ?? "";
|
||||
if (!MailAddress.TryCreate(email, out _) || !domain.Contains('.'))
|
||||
return "A valid email address is required.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NewRefreshToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||
private static string Base64Url(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
@@ -9,9 +9,12 @@ Inbound JSON documents
|
||||
{
|
||||
"username": "string",
|
||||
"password": "string",
|
||||
"email": "string (optional)"
|
||||
"email": "string"
|
||||
}
|
||||
```
|
||||
Usernames must be 3-24 characters containing only letters, numbers, or
|
||||
underscores. Passwords must contain at least 8 characters, and email is
|
||||
required with a valid address format.
|
||||
- LoginRequest (`POST /api/auth/login`)
|
||||
```json
|
||||
{
|
||||
@@ -42,7 +45,7 @@ Stored documents (MongoDB)
|
||||
"username": "string",
|
||||
"passwordHash": "string",
|
||||
"role": "USER | SUPER",
|
||||
"email": "string (optional)",
|
||||
"email": "string",
|
||||
"refreshToken": "string (optional)",
|
||||
"refreshTokenExpiry": "string (optional, ISO-8601 datetime)"
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ WORKDIR /src
|
||||
|
||||
# Copy project file first to take advantage of Docker layer caching
|
||||
COPY ["AuthApi.csproj", "./"]
|
||||
RUN dotnet restore "AuthApi.csproj"
|
||||
RUN dotnet restore "AuthApi.csproj" --disable-parallel
|
||||
|
||||
# Copy the remaining source and publish
|
||||
COPY . .
|
||||
RUN dotnet publish "AuthApi.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
RUN dotnet publish "AuthApi.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
namespace AuthApi.Models;
|
||||
|
||||
public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } }
|
||||
public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } public bool AcceptLegal { 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; } = ""; }
|
||||
public class UpdateProfileRequest { public string? DisplayName { get; set; } public string? Email { get; set; } }
|
||||
public class ChangePasswordRequest { public string CurrentPassword { get; set; } = ""; public string NewPassword { get; set; } = ""; }
|
||||
public class DeleteAccountRequest { public string Password { get; set; } = ""; public string Confirmation { get; set; } = ""; }
|
||||
public class CompleteSteamRequest
|
||||
{
|
||||
public string TransactionId { get; set; } = "";
|
||||
public string Action { get; set; } = "";
|
||||
public string Username { get; set; } = "";
|
||||
public string? Email { get; set; }
|
||||
public string Password { get; set; } = "";
|
||||
public bool AcceptLegal { get; set; }
|
||||
}
|
||||
public class AcceptLegalRequest { public bool Accept { get; set; } }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace AuthApi.Models;
|
||||
|
||||
public class SteamAuthTransaction
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = "";
|
||||
public string Status { get; set; } = "pending";
|
||||
public string? SteamId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
}
|
||||
@@ -11,6 +11,21 @@ public class User
|
||||
public string PasswordHash { get; set; } = default!;
|
||||
public string Role { get; set; } = "USER";
|
||||
public string? Email { get; set; }
|
||||
public string? DisplayName { get; set; }
|
||||
public DateTime? CreatedUtc { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public DateTime? RefreshTokenExpiry { get; set; }
|
||||
public string? SteamId { get; set; }
|
||||
public string? TermsAcceptedVersion { get; set; }
|
||||
public string? PrivacyAcceptedVersion { get; set; }
|
||||
public DateTime? LegalAcceptedUtc { get; set; }
|
||||
public List<LegalAcceptanceRecord> LegalAcceptances { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class LegalAcceptanceRecord
|
||||
{
|
||||
public string TermsVersion { get; set; } = "";
|
||||
public string PrivacyVersion { get; set; } = "";
|
||||
public DateTime AcceptedUtc { get; set; } = DateTime.UtcNow;
|
||||
public string Source { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddControllers();
|
||||
@@ -11,6 +12,27 @@ builder.Services.AddControllers();
|
||||
// DI
|
||||
builder.Services.AddSingleton<UserService>();
|
||||
builder.Services.AddSingleton<BlacklistService>();
|
||||
builder.Services.AddSingleton<SteamAuthTransactionService>();
|
||||
builder.Services.AddSingleton<JwtTokenService>();
|
||||
builder.Services.AddSingleton<SteamOpenIdService>();
|
||||
builder.Services.AddSingleton<LegalPolicy>();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
static RateLimitPartition<string> Policy(HttpContext context, int permits) =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = permits,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0
|
||||
});
|
||||
options.AddPolicy("steam-start", context => Policy(context, 10));
|
||||
options.AddPolicy("steam-poll", context => Policy(context, 120));
|
||||
options.AddPolicy("steam-complete", context => Policy(context, 20));
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
});
|
||||
|
||||
// Swagger + JWT auth in Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
@@ -80,6 +102,8 @@ app.UseSwaggerUI(o =>
|
||||
o.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1");
|
||||
o.RoutePrefix = "swagger";
|
||||
});
|
||||
app.UseRouting();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
See `DOCUMENTS.md` for request payloads and stored document shapes.
|
||||
|
||||
## Endpoints
|
||||
- `POST /api/auth/register` Register a new user.
|
||||
- `POST /api/auth/register` Register a USER account with a username, email, and password.
|
||||
- `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.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using AuthApi.Models;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
public class JwtTokenService
|
||||
{
|
||||
private readonly IConfiguration _cfg;
|
||||
|
||||
public JwtTokenService(IConfiguration cfg) => _cfg = cfg;
|
||||
|
||||
public (string Token, string Jti, DateTime ExpiresUtc) Generate(User user)
|
||||
{
|
||||
var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"] ?? throw new InvalidOperationException("Jwt:Key missing"));
|
||||
var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi";
|
||||
var audience = _cfg["Jwt:Audience"] ?? issuer;
|
||||
var credentials = 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 expiresUtc = DateTime.UtcNow.AddMinutes(15);
|
||||
var token = new JwtSecurityToken(issuer, audience, claims, expires: expiresUtc, signingCredentials: credentials);
|
||||
return (new JwtSecurityTokenHandler().WriteToken(token), jti, expiresUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using AuthApi.Models;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
public sealed record LegalPolicyStatus(
|
||||
string TermsVersion,
|
||||
string PrivacyVersion,
|
||||
DateTime EffectiveUtc,
|
||||
string TermsUrl,
|
||||
string PrivacyUrl,
|
||||
bool RequiresLegalAcceptance);
|
||||
|
||||
public sealed class LegalPolicy
|
||||
{
|
||||
public const string DefaultVersion = "2026-07-20";
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public LegalPolicy(IConfiguration configuration) => _configuration = configuration;
|
||||
|
||||
public string TermsVersion => _configuration["Legal:TermsVersion"] ?? DefaultVersion;
|
||||
public string PrivacyVersion => _configuration["Legal:PrivacyVersion"] ?? DefaultVersion;
|
||||
public string TermsUrl => _configuration["Legal:TermsUrl"] ?? "https://doctor.ranaze.com/terms.html";
|
||||
public string PrivacyUrl => _configuration["Legal:PrivacyUrl"] ?? "https://doctor.ranaze.com/privacy.html";
|
||||
public DateTime EffectiveUtc => DateTime.TryParse(_configuration["Legal:EffectiveUtc"], out var value)
|
||||
? value.ToUniversalTime()
|
||||
: new DateTime(2026, 7, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public bool IsCurrent(User user) =>
|
||||
user.TermsAcceptedVersion == TermsVersion && user.PrivacyAcceptedVersion == PrivacyVersion;
|
||||
|
||||
public LegalPolicyStatus Status(User? user = null) => new(
|
||||
TermsVersion,
|
||||
PrivacyVersion,
|
||||
EffectiveUtc,
|
||||
TermsUrl,
|
||||
PrivacyUrl,
|
||||
user is not null && !IsCurrent(user));
|
||||
|
||||
public void Accept(User user, string source, DateTime? acceptedUtc = null)
|
||||
{
|
||||
var timestamp = acceptedUtc ?? DateTime.UtcNow;
|
||||
user.LegalAcceptances ??= [];
|
||||
if (IsCurrent(user) && user.LegalAcceptances.Any(value =>
|
||||
value.TermsVersion == TermsVersion && value.PrivacyVersion == PrivacyVersion))
|
||||
return;
|
||||
user.TermsAcceptedVersion = TermsVersion;
|
||||
user.PrivacyAcceptedVersion = PrivacyVersion;
|
||||
user.LegalAcceptedUtc = timestamp;
|
||||
user.LegalAcceptances.Add(new LegalAcceptanceRecord
|
||||
{
|
||||
TermsVersion = TermsVersion,
|
||||
PrivacyVersion = PrivacyVersion,
|
||||
AcceptedUtc = timestamp,
|
||||
Source = source
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using AuthApi.Models;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
public class SteamAuthTransactionService
|
||||
{
|
||||
private readonly IMongoCollection<SteamAuthTransaction> _transactions;
|
||||
|
||||
public SteamAuthTransactionService(IConfiguration cfg)
|
||||
{
|
||||
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
|
||||
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
|
||||
var database = new MongoClient(connectionString).GetDatabase(databaseName);
|
||||
_transactions = database.GetCollection<SteamAuthTransaction>("SteamAuthTransactions");
|
||||
var ttl = Builders<SteamAuthTransaction>.IndexKeys.Ascending(x => x.ExpiresUtc);
|
||||
_transactions.Indexes.CreateOne(new CreateIndexModel<SteamAuthTransaction>(ttl,
|
||||
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
|
||||
}
|
||||
|
||||
public Task CreateAsync(SteamAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
|
||||
|
||||
public async Task<SteamAuthTransaction?> GetAsync(string id) =>
|
||||
await _transactions.Find(x => x.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<bool> MarkVerifiedAsync(string id, string steamId, string status, string? userId)
|
||||
{
|
||||
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
|
||||
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "pending") &
|
||||
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
|
||||
var update = Builders<SteamAuthTransaction>.Update
|
||||
.Set(x => x.SteamId, steamId)
|
||||
.Set(x => x.Status, status)
|
||||
.Set(x => x.UserId, userId);
|
||||
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> MarkCompleteAsync(string id, string steamId, string userId)
|
||||
{
|
||||
var filter = Builders<SteamAuthTransaction>.Filter.Eq(x => x.Id, id) &
|
||||
Builders<SteamAuthTransaction>.Filter.Eq(x => x.Status, "requires_account") &
|
||||
Builders<SteamAuthTransaction>.Filter.Eq(x => x.SteamId, steamId) &
|
||||
Builders<SteamAuthTransaction>.Filter.Gt(x => x.ExpiresUtc, DateTime.UtcNow);
|
||||
var update = Builders<SteamAuthTransaction>.Update
|
||||
.Set(x => x.Status, "complete")
|
||||
.Set(x => x.UserId, userId);
|
||||
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
public class SteamOpenIdService
|
||||
{
|
||||
public const string Endpoint = "https://steamcommunity.com/openid/login";
|
||||
private static readonly Regex ClaimedIdPattern = new(
|
||||
"^https?://steamcommunity\\.com/openid/id/(?<id>[0-9]{17})$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
private readonly IHttpClientFactory _httpClients;
|
||||
|
||||
public SteamOpenIdService(IHttpClientFactory httpClients) => _httpClients = httpClients;
|
||||
|
||||
public static string BuildAuthenticationUrl(string realm, string returnUrl)
|
||||
{
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["openid.ns"] = "http://specs.openid.net/auth/2.0",
|
||||
["openid.mode"] = "checkid_setup",
|
||||
["openid.return_to"] = returnUrl,
|
||||
["openid.realm"] = realm,
|
||||
["openid.identity"] = "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
["openid.claimed_id"] = "http://specs.openid.net/auth/2.0/identifier_select"
|
||||
};
|
||||
return Endpoint + "?" + string.Join("&", parameters.Select(pair =>
|
||||
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
|
||||
}
|
||||
|
||||
public async Task<string?> VerifyCallbackAsync(IQueryCollection query, string expectedReturnUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(query["openid.mode"], "id_res", StringComparison.Ordinal) ||
|
||||
!string.Equals(query["openid.op_endpoint"].ToString().TrimEnd('/'), Endpoint.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(query["openid.return_to"], expectedReturnUrl, StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var claimedId = query["openid.claimed_id"].ToString();
|
||||
if (!string.Equals(claimedId, query["openid.identity"], StringComparison.Ordinal))
|
||||
return null;
|
||||
var match = ClaimedIdPattern.Match(claimedId);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var fields = query
|
||||
.Where(pair => pair.Key.StartsWith("openid.", StringComparison.Ordinal))
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value.ToString());
|
||||
fields["openid.mode"] = "check_authentication";
|
||||
var client = _httpClients.CreateClient(nameof(SteamOpenIdService));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
using var response = await client.PostAsync(Endpoint, new FormUrlEncodedContent(fields), cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
var validation = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return IsValidAuthenticationResponse(validation) ? match.Groups["id"].Value : null;
|
||||
}
|
||||
|
||||
internal static bool IsValidAuthenticationResponse(string response) =>
|
||||
response.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(line => string.Equals(line, "is_valid:true", StringComparison.Ordinal));
|
||||
|
||||
internal static string? ExtractSteamId(string claimedId)
|
||||
{
|
||||
var match = ClaimedIdPattern.Match(claimedId);
|
||||
return match.Success ? match.Groups["id"].Value : null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using AuthApi.Models;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AuthApi.Services;
|
||||
|
||||
@@ -17,16 +19,55 @@ public class UserService
|
||||
|
||||
var keys = Builders<User>.IndexKeys.Ascending(u => u.Username);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<User>(keys, new CreateIndexOptions { Unique = true }));
|
||||
var steamKeys = Builders<User>.IndexKeys.Ascending(u => u.SteamId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<User>(steamKeys, new CreateIndexOptions<User>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = new BsonDocument("SteamId", new BsonDocument("$type", "string"))
|
||||
}));
|
||||
}
|
||||
|
||||
public Task<User?> GetByUsernameAsync(string username) =>
|
||||
_col.Find(u => u.Username == username).FirstOrDefaultAsync();
|
||||
public async Task<User?> GetByUsernameAsync(string username) =>
|
||||
await _col.Find(u => u.Username == username).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<User?> GetByIdAsync(string id) =>
|
||||
await _col.Find(u => u.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<User?> GetByEmailAsync(string email) =>
|
||||
await _col.Find(u => u.Email == email).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<List<User>> GetByLoginCandidatesAsync(string login)
|
||||
{
|
||||
var normalized = login.Trim();
|
||||
var usernameMatch = await GetByUsernameAsync(normalized);
|
||||
if (usernameMatch is not null)
|
||||
return [usernameMatch];
|
||||
var emailPattern = new BsonRegularExpression($"^{Regex.Escape(normalized)}$", "i");
|
||||
return await _col.Find(Builders<User>.Filter.Regex(u => u.Email, emailPattern)).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<User?> GetBySteamIdAsync(string steamId) =>
|
||||
await _col.Find(u => u.SteamId == steamId).FirstOrDefaultAsync();
|
||||
|
||||
public Task CreateAsync(User user) => _col.InsertOneAsync(user);
|
||||
|
||||
public Task UpdateAsync(User user) =>
|
||||
_col.ReplaceOneAsync(u => u.Id == user.Id, user);
|
||||
|
||||
public async Task<bool> TryLinkSteamAsync(string userId, string steamId, string refreshToken, DateTime refreshExpiry)
|
||||
{
|
||||
var filter = Builders<User>.Filter.Eq(u => u.Id, userId) &
|
||||
(Builders<User>.Filter.Eq(u => u.SteamId, null) | Builders<User>.Filter.Eq(u => u.SteamId, steamId));
|
||||
var update = Builders<User>.Update
|
||||
.Set(u => u.SteamId, steamId)
|
||||
.Set(u => u.RefreshToken, refreshToken)
|
||||
.Set(u => u.RefreshTokenExpiry, refreshExpiry);
|
||||
var result = await _col.UpdateOneAsync(filter, update);
|
||||
return result.MatchedCount == 1;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string id) => _col.DeleteOneAsync(u => u.Id == id);
|
||||
|
||||
public Task<List<User>> GetAllAsync() =>
|
||||
_col.Find(FilterDefinition<User>.Empty).ToListAsync();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"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" },
|
||||
"Steam": { "Realm": "https://pauth.ranaze.com", "ReturnUrl": "https://pauth.ranaze.com/api/Auth/steam/callback", "TransactionTtlMinutes": 5 },
|
||||
"Legal": { "TermsVersion": "2026-07-20", "PrivacyVersion": "2026-07-20", "EffectiveUtc": "2026-07-20T00:00:00Z", "TermsUrl": "https://doctor.ranaze.com/terms.html", "PrivacyUrl": "https://doctor.ranaze.com/privacy.html" },
|
||||
"Logging": { "LogLevel": { "Default": "Information" } },
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,26 @@ spec:
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
env:
|
||||
- name: QuestionsApi__BaseUrl
|
||||
value: http://step3-questions.step3-questions.svc.cluster.local
|
||||
- name: InternalPurge__Secret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: medmind-internal-api
|
||||
key: purge-secret
|
||||
- name: Steam__Realm
|
||||
value: https://pauth.ranaze.com
|
||||
- name: Steam__ReturnUrl
|
||||
value: https://pauth.ranaze.com/api/Auth/steam/callback
|
||||
- name: Steam__TransactionTtlMinutes
|
||||
value: "5"
|
||||
- name: Legal__TermsVersion
|
||||
value: "2026-07-20"
|
||||
- name: Legal__PrivacyVersion
|
||||
value: "2026-07-20"
|
||||
- name: Legal__EffectiveUtc
|
||||
value: "2026-07-20T00:00:00Z"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
@@ -16,40 +17,114 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CraftingApi", "CraftingApi\
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorldApi", "WorldApi\WorldApi.csproj", "{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AuthApi.Tests", "AuthApi.Tests\AuthApi.Tests.csproj", "{E5FE4B7B-6535-4D11-883A-63859E1B48CF}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
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}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Debug|x86.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
|
||||
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|x64.Build.0 = Release|Any CPU
|
||||
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{334F3B23-EFE8-6F1A-5E5F-9A2275D56E28}.Release|x86.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}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Debug|x86.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
|
||||
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1572BA36-8EFC-4472-BE74-0676B593AED9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{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}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Debug|x86.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
|
||||
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C343AFFB-9AB0-4B70-834C-3D2A21E2B506}.Release|x86.Build.0 = Release|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{72AA73C5-6A42-4F79-ADCC-19F10A66B9E0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2A6A80E9-2D58-4E42-8B93-483F2D6E6D42}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0B5EE564-C6E1-4BA7-B0E2-B86D363A9C74}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C8F20B54-2A76-4BE0-8DA8-E146D1AF4D10}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E5FE4B7B-6535-4D11-883A-63859E1B48CF}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||