Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a868aacc0 | |||
| 356003cffd |
@@ -1,5 +0,0 @@
|
|||||||
disable:
|
|
||||||
- class-definitions-order
|
|
||||||
- no-else-return
|
|
||||||
max-line-length: 120
|
|
||||||
max-file-lines: 3000
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
# Gitea Actions is GitHub-Actions-compatible: drop this in .gitea/workflows/, enable Actions on the
|
|
||||||
# repo ([actions] ENABLED=true in app.ini, Gitea >= 1.19) and register an act_runner — it then runs
|
|
||||||
# on every push / PR. Godot and gdtoolkit are installed with plain shell steps (not the chickensoft
|
|
||||||
# setup-godot action), because a self-hosted runner usually can't reach the GitHub action registry
|
|
||||||
# (unless DEFAULT_ACTIONS_URL is set to proxy github.com). Only actions/checkout and upload-artifact
|
|
||||||
# remain as external actions — both ship with Gitea's default proxy; if yours is offline, mirror them
|
|
||||||
# into your instance.
|
|
||||||
#
|
|
||||||
# It also runs once a day (schedule) as a safety net over main's tip. The `guard` job skips that daily
|
|
||||||
# run when the commit already passed — so an idle main isn't re-tested, but a commit whose push-time
|
|
||||||
# run was missed/skipped still gets verified. schedule needs Gitea >= 1.21; workflow_dispatch (the
|
|
||||||
# manual "Run workflow" button) needs >= 1.23; from 1.23 the cron is evaluated in UTC.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 4 * * *" # 04:00 UTC daily
|
|
||||||
|
|
||||||
# statuses:write lets the test job stamp a `ci/headless` commit status the guard reads back.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
statuses: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
# The Godot build the team runs (godot --version -> 4.7.beta.custom_build.417cd33bf). Pin this to a
|
|
||||||
# PUBLISHED build from https://github.com/godotengine/godot-builds/releases. If you run a custom
|
|
||||||
# build with no public binary, host the linux headless zip where the runner can reach it and set
|
|
||||||
# GODOT_URL to that (it overrides the constructed URL below).
|
|
||||||
GODOT_VERSION: "4.7-beta1"
|
|
||||||
GODOT_URL: ""
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# On push/PR this passes straight through. On the daily schedule it asks the API whether main's tip
|
|
||||||
# already carries a successful `ci/headless` status (stamped by an earlier run) and, if so, tells the
|
|
||||||
# test job to skip — that's the "don't re-test a commit that already ran" rule.
|
|
||||||
guard:
|
|
||||||
name: Decide whether to run
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
run: ${{ steps.decide.outputs.run }}
|
|
||||||
steps:
|
|
||||||
- name: Check for an existing pass on this commit
|
|
||||||
id: decide
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -u
|
|
||||||
if [ "${{ github.event_name }}" != "schedule" ]; then
|
|
||||||
echo "event=${{ github.event_name }} -> run"
|
|
||||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
api="${{ github.api_url }}"; [ -n "$api" ] || api="${{ github.server_url }}/api/v1"
|
|
||||||
curl -sfL -H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"$api/repos/${{ github.repository }}/commits/${{ github.sha }}/statuses?limit=100" \
|
|
||||||
-o /tmp/statuses.json || echo '[]' > /tmp/statuses.json
|
|
||||||
python3 - <<'PY'
|
|
||||||
import json, os
|
|
||||||
try:
|
|
||||||
data = json.load(open("/tmp/statuses.json"))
|
|
||||||
except Exception:
|
|
||||||
data = []
|
|
||||||
items = data.get("statuses", data) if isinstance(data, dict) else data
|
|
||||||
done = any(
|
|
||||||
s.get("context") == "ci/headless" and (s.get("state") or s.get("status")) == "success"
|
|
||||||
for s in (items or [])
|
|
||||||
)
|
|
||||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
|
||||||
f.write("run=%s\n" % ("false" if done else "true"))
|
|
||||||
print("commit already passed ci/headless -> skip" if done else "no prior pass -> run")
|
|
||||||
PY
|
|
||||||
|
|
||||||
test:
|
|
||||||
name: Lint & headless tests
|
|
||||||
runs-on: ubuntu-latest # must match a label your act_runner registered with
|
|
||||||
needs: guard
|
|
||||||
if: ${{ needs.guard.outputs.run == 'true' }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Godot ${{ env.GODOT_VERSION }}
|
|
||||||
run: |
|
|
||||||
set -eux
|
|
||||||
url="${GODOT_URL:-https://github.com/godotengine/godot-builds/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_linux.x86_64.zip}"
|
|
||||||
curl -fL "$url" -o /tmp/godot.zip
|
|
||||||
unzip -o /tmp/godot.zip -d /tmp/godot
|
|
||||||
install -m 755 /tmp/godot/Godot_v*_linux.x86_64 /usr/local/bin/godot
|
|
||||||
godot --version
|
|
||||||
|
|
||||||
- name: Install gdlint (gdtoolkit)
|
|
||||||
# A venv sidesteps PEP-668 "externally managed" pip on recent runner images; putting its bin on
|
|
||||||
# GITHUB_PATH lets run_tests.sh call bare `gdlint` in later steps.
|
|
||||||
run: |
|
|
||||||
set -eux
|
|
||||||
python3 -m venv /tmp/venv
|
|
||||||
/tmp/venv/bin/pip install "gdtoolkit==4.*"
|
|
||||||
echo "/tmp/venv/bin" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
# A cold .godot cache (CI always starts cold) needs two import passes: the first imports base
|
|
||||||
# assets, the second resolves scenes that depend on them (the .fbx/.glb -> .scn imports). One pass
|
|
||||||
# leaves those unresolved and the headless tests fail to load them. Warnings exit non-zero.
|
|
||||||
- name: Import resources
|
|
||||||
run: |
|
|
||||||
godot --headless --import || true
|
|
||||||
godot --headless --import || true
|
|
||||||
|
|
||||||
- name: gdlint
|
|
||||||
run: gdlint actors systems ui props levels tests
|
|
||||||
|
|
||||||
- name: Headless tests
|
|
||||||
env:
|
|
||||||
GODOT: godot
|
|
||||||
run: bash run_tests.sh --no-gameplay
|
|
||||||
|
|
||||||
# Stamp the commit so the daily guard recognizes it as already tested. Best-effort: a fork PR whose
|
|
||||||
# token can't write statuses just skips this (the build still passes).
|
|
||||||
- name: Mark commit as tested
|
|
||||||
if: success()
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
api="${{ github.api_url }}"; [ -n "$api" ] || api="${{ github.server_url }}/api/v1"
|
|
||||||
curl -sfL -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
|
|
||||||
"$api/repos/${{ github.repository }}/statuses/${{ github.sha }}" \
|
|
||||||
-d '{"context":"ci/headless","state":"success","description":"headless suite green"}' \
|
|
||||||
>/dev/null || echo "could not post commit status (token lacks statuses:write?)"
|
|
||||||
|
|
||||||
- name: Upload test output on failure
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-output
|
|
||||||
path: tests/output/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
# Godot 4+ specific ignores
|
|
||||||
.godot/
|
|
||||||
/android/
|
|
||||||
|
|
||||||
# Godot editor autosave scratch files (e.g. Bear.tscn12015901897.tmp)
|
|
||||||
*.tmp
|
|
||||||
|
|
||||||
# JetBrains IDE — contains absolute paths (Godot binary, SDK dirs), varies per user
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# Blender render previews (generated by render_anim_preview.py)
|
|
||||||
art/blender/preview/
|
|
||||||
|
|
||||||
# Blender auto-save backups (.blend1, .blend2, … — Blender rewrites these on every save)
|
|
||||||
art/blender/*.blend[0-9]
|
|
||||||
|
|
||||||
# Test output (generated screenshots, frame strips)
|
|
||||||
tests/output/
|
|
||||||
|
|
||||||
# Web export output
|
|
||||||
build/
|
|
||||||
|
|
||||||
#PRISM
|
|
||||||
BullPrism/
|
|
||||||
|
|
||||||
Generated
-15
@@ -1,15 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Rider ignored files
|
|
||||||
/.idea.Bullosseum.iml
|
|
||||||
/projectSettingsUpdater.xml
|
|
||||||
/contentModel.xml
|
|
||||||
/modules.xml
|
|
||||||
# Ignored default folder with query files
|
|
||||||
/queries/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
Generated
-4
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
|
||||||
</project>
|
|
||||||
Generated
-8
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="UserContentModel">
|
|
||||||
<attachedFolders />
|
|
||||||
<explicitIncludes />
|
|
||||||
<explicitExcludes />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Godot 4+ specific ignores
|
||||||
|
.godot/
|
||||||
|
/android/
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
[gd_scene format=3 uid="uid://crtm0xtiql3r8"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://bn777gguclud5" path="res://player.gd" id="1_xhfnw"]
|
||||||
|
[ext_resource type="Script" uid="uid://b4td4k8yo5y78" path="res://camera_spring_arm.gd" id="2_kpjcp"]
|
||||||
|
[ext_resource type="Script" uid="uid://b720l6m0ehrj3" path="res://camera_follow.gd" id="3_kne1u"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_xhfnw"]
|
||||||
|
size = Vector3(1, 1.4072266, 1)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_0rwas"]
|
||||||
|
|
||||||
|
[sub_resource type="SphereShape3D" id="SphereShape3D_kne1u"]
|
||||||
|
radius = 0.3
|
||||||
|
|
||||||
|
[node name="Player" type="CharacterBody3D" unique_id=165510044]
|
||||||
|
script = ExtResource("1_xhfnw")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1104396769]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.20361328, 0)
|
||||||
|
shape = SubResource("BoxShape3D_xhfnw")
|
||||||
|
|
||||||
|
[node name="CubeGuy" type="Node3D" parent="." unique_id=1651396216]
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="CubeGuy" unique_id=367855086]
|
||||||
|
mesh = SubResource("BoxMesh_0rwas")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D2" type="MeshInstance3D" parent="CubeGuy" unique_id=1565560527]
|
||||||
|
transform = Transform3D(0.22778136, 0, 0, 0, 0.4572928, 0, 0, 0, 0.2800231, 0.36544353, -0.65451574, 0)
|
||||||
|
mesh = SubResource("BoxMesh_0rwas")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D3" type="MeshInstance3D" parent="CubeGuy" unique_id=2097296241]
|
||||||
|
transform = Transform3D(0.22778136, 0, 0, 0, 0.4572928, 0, 0, 0, 0.2800231, -0.38543648, -0.65451574, 0)
|
||||||
|
mesh = SubResource("BoxMesh_0rwas")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D4" type="MeshInstance3D" parent="CubeGuy" unique_id=383816037]
|
||||||
|
transform = Transform3D(0.14033328, 0.3601992, 0, -0.17941824, 0.28173247, 0, 0, 0, 0.28002307, -0.60614854, -0.07838535, 0)
|
||||||
|
mesh = SubResource("BoxMesh_0rwas")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D5" type="MeshInstance3D" parent="CubeGuy" unique_id=326472138]
|
||||||
|
transform = Transform3D(-0.14033328, -0.3601992, 2.4480395e-08, -0.17941824, 0.28173247, 0, -1.2268325e-08, -3.1489616e-08, -0.28002307, 0.6122027, -0.07838535, 0)
|
||||||
|
mesh = SubResource("BoxMesh_0rwas")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D6" type="MeshInstance3D" parent="CubeGuy" unique_id=1642768377]
|
||||||
|
transform = Transform3D(-0.113692805, 0, -2.4480393e-08, 0, 0.11736439, 0, 9.939342e-09, 0, -0.28002304, -0.26236355, 0.3103568, 0.42819703)
|
||||||
|
mesh = SubResource("BoxMesh_0rwas")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D7" type="MeshInstance3D" parent="CubeGuy" unique_id=695468518]
|
||||||
|
transform = Transform3D(-0.113692805, 0, -2.4480393e-08, 0, 0.11736439, 0, 9.939342e-09, 0, -0.28002304, 0.2908606, 0.28320956, 0.42819703)
|
||||||
|
mesh = SubResource("BoxMesh_0rwas")
|
||||||
|
|
||||||
|
[node name="SpringArmPivot" type="Node3D" parent="." unique_id=1671817680]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.22, 0)
|
||||||
|
script = ExtResource("2_kpjcp")
|
||||||
|
|
||||||
|
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmPivot" unique_id=141439848]
|
||||||
|
collision_mask = 2
|
||||||
|
shape = SubResource("SphereShape3D_kne1u")
|
||||||
|
spring_length = 7.0
|
||||||
|
|
||||||
|
[node name="SpringPosition" type="Node3D" parent="SpringArmPivot/SpringArm3D" unique_id=1307131677]
|
||||||
|
|
||||||
|
[node name="Camera3D" type="Camera3D" parent="SpringArmPivot" unique_id=1120161087 node_paths=PackedStringArray("spring_arm")]
|
||||||
|
keep_aspect = 0
|
||||||
|
script = ExtResource("3_kne1u")
|
||||||
|
spring_arm = NodePath("../SpringArm3D/SpringPosition")
|
||||||
|
lerp_power = 6.0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
extends Camera3D
|
||||||
|
|
||||||
|
|
||||||
|
@export var spring_arm: Node3D
|
||||||
|
@export var lerp_power: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
position = lerp(position, spring_arm.position, delta * lerp_power)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b720l6m0ehrj3
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
extends Node3D
|
||||||
|
|
||||||
|
|
||||||
|
@export var mouse_sensitivity: float = 0.005
|
||||||
|
@export_range(-90.0, 0.0, 0.1, "radians_as_degrees") var min_vertical_angle: float = -PI/2
|
||||||
|
@export_range(0.0, 90.0, 0.1, "radians_as_degrees") var max_vertical_angle: float = PI/4
|
||||||
|
|
||||||
|
@onready var spring_arm := $SpringArm3D
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event : InputEvent) -> void:
|
||||||
|
if event is InputEventMouseMotion:
|
||||||
|
rotation.y -= event.relative.x * mouse_sensitivity
|
||||||
|
rotation.y = wrapf(rotation.y, 0.0, TAU)
|
||||||
|
|
||||||
|
rotation.x -= event.relative.y * mouse_sensitivity
|
||||||
|
rotation.x = clamp(rotation.x, min_vertical_angle, max_vertical_angle)
|
||||||
|
|
||||||
|
if event.is_action_pressed("wheel_up"):
|
||||||
|
spring_arm.spring_length -=1
|
||||||
|
if event.is_action_pressed("wheel_down"):
|
||||||
|
spring_arm.spring_length +=1
|
||||||
|
|
||||||
|
if event.is_action_pressed("toggle_mouse_capture"):
|
||||||
|
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
else:
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b4td4k8yo5y78
|
||||||
|
Before Width: | Height: | Size: 995 B After Width: | Height: | Size: 995 B |
@@ -0,0 +1,28 @@
|
|||||||
|
extends CharacterBody3D
|
||||||
|
|
||||||
|
|
||||||
|
const SPEED = 5.0
|
||||||
|
const JUMP_VELOCITY = 4.5
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
# Add the gravity.
|
||||||
|
if not is_on_floor():
|
||||||
|
velocity += get_gravity() * delta
|
||||||
|
|
||||||
|
# Handle jump.
|
||||||
|
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
|
||||||
|
velocity.y = JUMP_VELOCITY
|
||||||
|
|
||||||
|
# Get the input direction and handle the movement/deceleration.
|
||||||
|
# As good practice, you should replace UI actions with custom gameplay actions.
|
||||||
|
var input_dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
|
||||||
|
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
|
||||||
|
if direction:
|
||||||
|
velocity.x = direction.x * SPEED
|
||||||
|
velocity.z = direction.z * SPEED
|
||||||
|
else:
|
||||||
|
velocity.x = move_toward(velocity.x, 0, SPEED)
|
||||||
|
velocity.z = move_toward(velocity.z, 0, SPEED)
|
||||||
|
|
||||||
|
move_and_slide()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
; Engine configuration file.
|
||||||
|
; It's best edited using the editor UI and not directly,
|
||||||
|
; since the parameters that go here are not all obvious.
|
||||||
|
;
|
||||||
|
; Format:
|
||||||
|
; [section] ; section goes between []
|
||||||
|
; param=value ; assign values to parameters
|
||||||
|
|
||||||
|
config_version=5
|
||||||
|
|
||||||
|
[application]
|
||||||
|
|
||||||
|
config/name="3rd_person_camera"
|
||||||
|
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||||
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
|
[input]
|
||||||
|
|
||||||
|
wheel_up={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":8,"position":Vector2(726, 8),"global_position":Vector2(735, 56),"factor":1.0,"button_index":4,"canceled":false,"pressed":true,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
wheel_down={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":16,"position":Vector2(400, 21),"global_position":Vector2(409, 69),"factor":1.0,"button_index":5,"canceled":false,"pressed":true,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
toggle_mouse_capture={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":4,"position":Vector2(461, 22),"global_position":Vector2(470, 70),"factor":1.0,"button_index":3,"canceled":false,"pressed":true,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
[physics]
|
||||||
|
|
||||||
|
3d/physics_engine="Jolt Physics"
|
||||||
|
|
||||||
|
[rendering]
|
||||||
|
|
||||||
|
rendering_device/driver.windows="d3d12"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[gd_scene format=3 uid="uid://b7afbx36ltb3f"]
|
||||||
|
|
||||||
|
[ext_resource type="PackedScene" uid="uid://crtm0xtiql3r8" path="res://Player.tscn" id="1_ulcgi"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_nxogm"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_ulcgi"]
|
||||||
|
|
||||||
|
[node name="scene" type="Node3D" unique_id=22320312]
|
||||||
|
|
||||||
|
[node name="Player" parent="." unique_id=165510044 instance=ExtResource("1_ulcgi")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.3040223, 0)
|
||||||
|
|
||||||
|
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=818712731]
|
||||||
|
transform = Transform3D(0.030703627, 0.98235905, -0.18446636, -0.1612905, 0.18700416, 0.9690277, 0.9864293, -1.2735805e-08, 0.16418687, -4.08134, 0.3070748, 0.97087634)
|
||||||
|
shadow_enabled = true
|
||||||
|
|
||||||
|
[node name="Node3D" type="Node3D" parent="." unique_id=1081100876]
|
||||||
|
transform = Transform3D(3.5710833, 0, 0, 0, 1, 0, 0, 0, 2.2964425, 0, 0, 0)
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Node3D" unique_id=1193968330]
|
||||||
|
transform = Transform3D(7.6892195, 0, 0, 0, 0.34217757, 0, 0, 0, 8.638922, 0, 0, 0)
|
||||||
|
mesh = SubResource("BoxMesh_nxogm")
|
||||||
|
|
||||||
|
[node name="StaticBody3D" type="StaticBody3D" parent="Node3D" unique_id=2072095624]
|
||||||
|
transform = Transform3D(1.0000001, 0, 0, 0, 1.0000001, 0, 0, 0, 1.0000001, 0, 0, 0)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Node3D/StaticBody3D" unique_id=1444198204]
|
||||||
|
transform = Transform3D(7.6892185, 0, 0, 0, 0.34217754, 0, 0, 0, 8.638921, 0, 0, 0)
|
||||||
|
shape = SubResource("BoxShape3D_ulcgi")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,199 +0,0 @@
|
|||||||
# Bullosseum — CLAUDE.md
|
|
||||||
|
|
||||||
## About this project
|
|
||||||
|
|
||||||
A 3D arena game built in Godot 4.7 (Forward Plus renderer, Jolt Physics) where the player controls a bull. Currently features third-person camera, bull movement with charge mechanics, and a placeholder arena.
|
|
||||||
|
|
||||||
## AI role
|
|
||||||
|
|
||||||
I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scene architecture, physics, and performance. If a question suggests a suboptimal approach — wrong node type, unnecessary complexity, a pattern that fights the engine — I will say so and explain the better alternative before implementing anything.
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
Files are grouped by **role**, not by type. Scripts and scenes are `snake_case`;
|
|
||||||
`PascalCase` is reserved for `class_name` identifiers (this avoids case-insensitive
|
|
||||||
filesystem aliasing on the web/macOS targets).
|
|
||||||
|
|
||||||
```
|
|
||||||
actors/{player,matador,bear}/ the three characters (script + scene together)
|
|
||||||
systems/combat/ hitbox.gd, damage_event.gd, physics_layers.gd
|
|
||||||
systems/fx/ Fx autoload + effect scenes (Siim's authoring dir)
|
|
||||||
systems/input/ controls_manager.gd, touch_controls.gd
|
|
||||||
systems/debug/ debug_params.gd (DP), debug_draw.gd, console.gd
|
|
||||||
systems/render/ rigid_skin.gd, ps1_filter (post-process)
|
|
||||||
systems/crowd/ billboard audience
|
|
||||||
systems/config/ settings.gd (Settings autoload)
|
|
||||||
ui/ hud, mobile_hud, main_menu, ui_theme/fonts, overlays
|
|
||||||
props/ barrels, torch
|
|
||||||
levels/ game_shell.tscn + level modules
|
|
||||||
tools/ tests/ editor/bake scripts, headless tests
|
|
||||||
art/ all raw art, grouped by role (snake_case):
|
|
||||||
characters/ levels/ weapons/ bull/matador/bear, arena/lobby geometry, spear/sword
|
|
||||||
fx/ anim/ textures/ bear_fx, crowd_clap, baked sheets
|
|
||||||
ui/ icons/ fonts/ menu art, ability icons, Cinzel fonts
|
|
||||||
hdri/ materials/ sky HDRIs, shared StandardMaterial3D resources
|
|
||||||
blender/ Siim's .blend working files (Godot-ignored)
|
|
||||||
```
|
|
||||||
|
|
||||||
| Path | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `levels/game_shell.tscn` | The reusable **shell** loaded for every level: player, camera, HUD, PS1 filter, and an empty `LevelRoot`. Its `game_shell.gd` instances the active level's module into `LevelRoot` at runtime |
|
|
||||||
| `levels/` | The level system: per-level module scenes plus the code that defines, loads and sequences them (see rows below) |
|
|
||||||
| `levels/game_shell.gd` | Shell controller — on load, frees any current level module and instances `Run.active_level().level_scene` into `LevelRoot`. Swapping the whole subtree (never hiding it) is what keeps one level's meshes/colliders/spawners from leaking into the next |
|
|
||||||
| `levels/arena_level.tscn` | The arena **level module**: floor + wall colliders, arena meshes, `MatadorSpawner` (with child `Node3D` spawn points placed by the designer), lighting, audience (`PublikNode`). Instanced into the shell's `LevelRoot` |
|
|
||||||
| `levels/bear_level.tscn` | The Bear's Den **level module**: bear-arena mesh, its own floor collider, lighting, and a `BearSpawner` (`bear_spawn.gd`) that places the bear at center. Instanced into the shell's `LevelRoot` |
|
|
||||||
| `actors/player/player.tscn` | Player scene root (`CharacterBody3D`) |
|
|
||||||
| `actors/player/player.gd` | Movement, charge, turning, and abilities (kick/dash/slam/roll). Ability & movement particle FX are **not** built here — they're authored scenes in `systems/fx/` (`bull_*.tscn`), fired via `Fx.spawn()` (one-shots) / `Fx.attach()` (continuous) |
|
|
||||||
| `actors/player/bull_impact_fx.gd` | Bull's coded, non-particle impact FX (the slam shockwave rings + kick flash-pop — tweened meshes, not CPUParticles3D, so they can't be `systems/fx/` scenes). Created by `player.gd` |
|
|
||||||
| `actors/player/bull_legs.gd` | Bull body-wobble + tail Verlet-chain simulation. Created by `player.gd` |
|
|
||||||
| `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) |
|
|
||||||
| `camera_follow.gd` | Smooth camera follow (`Camera3D`) |
|
|
||||||
| `actors/matador/matador.gd` | Matador AI (wander / ragdoll state machine) |
|
|
||||||
| `levels/matador_spawn.gd` | Arena spawner — one matador per designer-placed child `Node3D` spawn point (each enters with a jump-roll toward center); falls back to a programmatic spread if no spawn points are present. Also handles the split-on-death hydra logic |
|
|
||||||
| `levels/bear_spawn.gd` | Bear-level spawner — instantiates `enemy_scene` at the arena center; emits the same `all_defeated` / `matador_killed` signals as `matador_spawn.gd` |
|
|
||||||
| `actors/bear/bear.tscn` / `actors/bear/bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. A horns-first bull charge wounds it (`_sweep_bull_ram`: swept prev→now so a tunnelling/point-blank charge still lands, gated by `bear_ram_reach` + `bull_gore_arc`, edge-triggered = one charge per wound). Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section |
|
|
||||||
| `actors/bear/bear_attack_hitboxes.tscn` | The bear's attack colliders as a self-contained sub-scene (instanced under `bear_model`): a `LeapSlamHitbox` (Hitbox + cylinder = ground circle) and `SmashHitbox` (Hitbox + box = forward shockwave lane), plus an `AnimationPlayer` whose `leap_slam` / `smash_travel` clips author each collider's size + travel — scrub to tweak, like `Bear_FX`. `actors/bear/bear.gd` only fires the clip, arms the knock-up shaping, and sweeps for overlaps while it runs (see `_setup_attack_hitboxes` / `_start_attack_hitbox`) |
|
|
||||||
| `actors/matador/sword.tscn` / `systems/combat/hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `systems/combat/hitbox.gd` (`class_name Hitbox`, Area3D) is the shared attack collider: `activate()`/`deactivate()` gate an active window, `sweep()` catches bodies already inside (AoE), and knock-up can ramp by distance (`knock_up_min_dist`/`_max_dist`) for the bear's outer-ring launch / further-hit-flies-higher |
|
|
||||||
| `levels/run_state.gd` | Run progression autoload (`Run`) — the lobby hub and which level is being fought, across scene reloads |
|
|
||||||
| `levels/level_def.gd` | `LevelDef` resource — a level's name / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) |
|
|
||||||
| `systems/crowd/crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
|
|
||||||
| `systems/crowd/crowd_billboards.gd` / `systems/crowd/crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
|
|
||||||
| `systems/debug/debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `systems/input/touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `systems/render/rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag |
|
|
||||||
| `systems/input/touch_controls.gd` | On-screen thumb UI for touch/web (`CanvasLayer`, instanced by `ui/hud.gd`, self-gating on `Controls.use_touch_ui()`): a floating left-side joystick feeding the `move_*` actions + a right-side ability cluster, drawn/hit-tested in one IGNORE-filtered Control from raw `_input()`. The joystick self-heals — the latest left-zone touch re-acquires it (`_stick_index`), and a relayout / focus-out / vanished touch resets state so a dropped touchend can't strand a stuck stick. Ability buttons use grown hit-rects + a press flash. `_draw_debug()` (gated on `Controls.debug_overlay()` — DP `touch_debug` or `?debug=1`) is a live on-device profiler reading Godot's `Performance` monitors: FPS/frame-ms with a bottleneck verdict, `process`(main-thread GDScript) vs `physics`(ragdoll/Jolt) ms with 2 s peak-hold, draw calls/objects/primitives, node count, render resolution, and `crossOriginIsolated` — so a lag spike is pinned to CPU-script vs CPU-physics vs GPU-fill instead of guessed |
|
|
||||||
| `ui/web_start_gate.gd` | Autoload `WebStartGate` — "TAP TO PLAY" panel shown on web+touch before play; the tap drives `Controls.request_fullscreen_landscape()` so the fullscreen/orientation churn happens with no in-flight touch to lose. Re-armable: polls browser fullscreen state and returns if the player leaves it (iPhone Safari can't fullscreen, so it stops nagging once a request never takes) |
|
|
||||||
| `ui/mobile_hud.gd` | Mobile-only health readouts (`CanvasLayer`, instanced by `ui/hud.gd` only on touch): big bull pip row top-left + a Souls-style bear boss bar top-centre (discovered via group `&"bear"`, tracks `health_changed`). The desktop bottom pip cluster is hidden on touch |
|
|
||||||
| `ui/orientation_guard.gd` | Autoload `OrientationGuard` — full-screen "rotate your device" prompt shown on touch devices while portrait |
|
|
||||||
| `art/` | All raw art, grouped by role (`characters/ levels/ weapons/ fx/ anim/ textures/ ui/ icons/ fonts/`). GLBs use **Extract Textures** import, so their embedded textures land beside them (e.g. `art/levels/arena_*.png`) named from the GLB — expected, keep them tracked |
|
|
||||||
| `art/blender/` | Blender source files, animation scripts, FBX exports (Godot-ignored via `.gdignore`) |
|
|
||||||
| `art/blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX |
|
|
||||||
| `art/blender/render_anim_preview.py` | Renders animation preview PNGs for visual verification |
|
|
||||||
| `art/blender/preview/` | Output directory for animation preview images |
|
|
||||||
| `tests/` | Headless test scripts (screenshot capture, logic validation) |
|
|
||||||
| `addons/rider-plugin/` | JetBrains Rider IDE integration |
|
|
||||||
|
|
||||||
## Tech choices
|
|
||||||
|
|
||||||
- **Physics:** Jolt Physics (not the default Godot Physics)
|
|
||||||
- **Renderer:** Forward Plus
|
|
||||||
- **Input map:** WASD + arrow keys, Shift = charge, Space = jump, scroll = zoom, middle-click = toggle mouse capture
|
|
||||||
- **IDE:** JetBrains Rider via the rider-plugin addon
|
|
||||||
|
|
||||||
## GDScript conventions
|
|
||||||
|
|
||||||
- Typed GDScript everywhere (`var x: float`, return types on functions)
|
|
||||||
- `@onready` for node references; never `get_node()` strings when avoidable
|
|
||||||
- Constants in `SCREAMING_SNAKE_CASE`, variables in `snake_case`
|
|
||||||
- One script per scene root — keep scripts focused
|
|
||||||
- Signal names in `snake_case`; connect via `signal.connect()` not the legacy string form
|
|
||||||
- Prefer `_physics_process` for physics/movement, `_process` for visuals/camera, `_unhandled_input` for input that shouldn't bubble
|
|
||||||
- No comments that restate what the code already says; only comment non-obvious constraints or workarounds
|
|
||||||
|
|
||||||
## Best practices to enforce
|
|
||||||
|
|
||||||
- Use `CharacterBody3D` for player-controlled characters, not `RigidBody3D` (unless the design specifically needs physics simulation)
|
|
||||||
- Use `SpringArm3D` for third-person cameras to get free collision avoidance
|
|
||||||
- Prefer `move_and_slide()` with `velocity` over manual collision queries
|
|
||||||
- Export variables (`@export`) for any value a designer might tune; keep magic numbers out of logic
|
|
||||||
- Scene composition over inheritance — build behaviour from small focused scenes
|
|
||||||
- Use `autoload` (singletons) sparingly: only for truly global state (e.g. GameManager, AudioBus); not as a shortcut for passing data
|
|
||||||
- Keep `_physics_process` deterministic and frame-rate independent (always multiply by `delta`)
|
|
||||||
- Prefer signals over direct node references for decoupling
|
|
||||||
|
|
||||||
## Animation & bone conventions
|
|
||||||
|
|
||||||
### Blender rig (matador)
|
|
||||||
- **Armature bones:** `matador` (root) → `COG` → `chest` → `head`, `collarbone_L/R` → `arm_L/R` → `forearm_L/R` → `hand_L/R`, `leg_L/R` → `shin_L/R` → `foot_L/R`
|
|
||||||
- **Rotation mode:** All animated bones use **XYZ Euler** (set in `create_matador_anims.py`)
|
|
||||||
- **Bone-local axes:** Swing (forward/back) is on **local X**. Arm lowering from T-pose is on **local Y** (positive = left arm down, negative = right arm down). Knee bend is **positive X** (shins only bend backward).
|
|
||||||
- **Walk cycle:** 30 frames at 30 fps = 1 second loop. Legs swing ±25° X, knees bend 0–30° X (only when leg is back), arms swing ±15° X opposite phase to legs, arms lowered 55° Y from T-pose, elbows constant 25° X bend.
|
|
||||||
- **Idle pose:** Arms lowered 55° Y, elbows 25° X, everything else at rest.
|
|
||||||
|
|
||||||
### FBX export settings
|
|
||||||
- `primary_bone_axis = 'Y'`, `secondary_bone_axis = 'X'`
|
|
||||||
- `apply_scale_options = 'FBX_SCALE_ALL'`
|
|
||||||
- `add_leaf_bones = False`
|
|
||||||
- `bake_anim_use_all_actions = True`, `bake_anim_use_nla_strips = False`
|
|
||||||
|
|
||||||
### Godot animation names
|
|
||||||
After FBX import, animations appear as `Armature|walk` and `Armature|idle` in AnimationPlayer.
|
|
||||||
|
|
||||||
### Verification workflow
|
|
||||||
1. Edit bones/animations in Blender (`create_matador_anims.py`)
|
|
||||||
2. Run render preview: `blender --background art/blender/matador.blend --python art/blender/render_anim_preview.py`
|
|
||||||
3. Inspect PNGs in `art/blender/preview/` — check bone orientations **before** exporting to Godot
|
|
||||||
4. Export FBX (done automatically by `create_matador_anims.py`)
|
|
||||||
5. Re-import in Godot and test in-game
|
|
||||||
|
|
||||||
## Running tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All tests (lint + logic + gameplay assertions):
|
|
||||||
bash run_tests.sh
|
|
||||||
|
|
||||||
# Individual tests:
|
|
||||||
gdlint actors systems ui props levels tests # GDScript lint (gdtoolkit, installed via uv)
|
|
||||||
godot --headless --script tests/logic_test.gd # pure logic, ~2 s
|
|
||||||
godot --headless --script tests/harness_selftest.gd # shared test_helpers: seeded RNG + approx + tally, ~1 s
|
|
||||||
godot --headless --script tests/damage_pipeline_test.gd # bull's hit reception (take_sword_hit + typed receive_damage/DamageEvent), ~3 s
|
|
||||||
godot --headless --script tests/fx_gallery_test.gd # every registered Fx effect spawns + self-frees, ~2 s
|
|
||||||
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
|
|
||||||
godot --headless --script tests/ragdoll_perf_test.gd # on-hit spike breakdown: ragdoll build / sim-start / blood / mass hit, ~90 s
|
|
||||||
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
|
|
||||||
godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s
|
|
||||||
godot --headless --script tests/touch_controls_test.gd # joystick self-heals: relayout/focus-out/vanished-touch never strand a stuck stick, ~2 s
|
|
||||||
godot --headless --script tests/ui_layout_test.gd # UI layout invariants (no pixels): HUD/menu stay on-screen, text fits, tap targets ≥44px, scroll works — across resolutions + pseudo-locale, ~6 s
|
|
||||||
godot --headless --script tests/rigid_skin_test.gd # web mesh conversion keeps every material (no bald / recoloured matadors), ~2 s
|
|
||||||
godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s
|
|
||||||
godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s
|
|
||||||
godot --headless --script tests/bear_hitbox_test.gd # bear attack colliders: leap launches everywhere (ring_frac slider) + smash lane travel, ~3 s
|
|
||||||
godot --headless --script tests/bear_ram_test.gd # a horns-first bull charge wounds the bear once; away/wide/slow spared, ~3 s
|
|
||||||
godot --headless --script tests/matador_stab_test.gd # matador's BladeHitbox thrust gores a stationary bull, and a landed stab breaks it off to taunt (anti-spam), ~2 s
|
|
||||||
godot --headless --script tests/matador_taunt_react_test.gd # a taunting matador answers a charge: spear from range, sword up close, ignores non-charges, ~2 s
|
|
||||||
godot --headless --script tests/matador_charge_test.gd # bull's charge is swept (tunnel-proof) so a fast dash still gores; a committed charge pierces the survival roll, ~2 s
|
|
||||||
godot --headless --script tests/matador_spear_test.gd # thrown spear masks the BULL layer and hits a stationary bull, ~2 s
|
|
||||||
|
|
||||||
# Gameplay test with real rendering (inspect frame strip visually):
|
|
||||||
godot --script tests/gameplay_test.gd # opens a window, saves real screenshots
|
|
||||||
# Output: tests/output/gameplay/motion_00..04.png, ragdoll_trigger.png, ragdoll_result.png
|
|
||||||
|
|
||||||
# Difficulty simulation (balance tool, ~2 min):
|
|
||||||
godot --headless --script tests/difficulty_sim.gd # drives a bot bull vs one matador
|
|
||||||
```
|
|
||||||
|
|
||||||
### Difficulty simulation (`tests/difficulty_sim.gd`)
|
|
||||||
A balance tool, not a pass/fail test. It drives a kinematic "bot bull" against one
|
|
||||||
real matador across behaviour profiles (CHARGER / KITER / PASSIVE) and reports the
|
|
||||||
matador's win rate (how often it gores the bull). Use it to tune matador difficulty
|
|
||||||
from data. The bot bull has NO abilities (it only kills by ramming), so it can't use
|
|
||||||
roll/slam/kick — real skilled play beats the matador more than these numbers imply;
|
|
||||||
read the rates as "how well the matador threatens each movement pattern." A rough
|
|
||||||
target is CHARGER high (blind charging is punished), KITER moderate (throws are a
|
|
||||||
dodgeable threat), PASSIVE high (standing still dies). Note: as a `--script` entry it
|
|
||||||
reads the `DP` autoload via `/root/DP`, not the global identifier.
|
|
||||||
|
|
||||||
### What the gameplay test catches
|
|
||||||
| Check | What it detects |
|
|
||||||
|---|---|
|
|
||||||
| Tail Verlet chain spread | Tail bunching — consecutive nodes collapsed to same position |
|
|
||||||
| Ragdoll bone positions finite + within 15 m | Matador limbs exploding after ragdoll impulse |
|
|
||||||
| Ragdoll Y position > −10 | Bones falling through the floor |
|
|
||||||
| Frame strip (visual, manual) | Leg IK quality, animation glitches, anything that looks wrong in motion |
|
|
||||||
|
|
||||||
Headless screenshots will be blank (Forward Plus has no display). Run without `--headless` to get real frame strips.
|
|
||||||
|
|
||||||
### What the performance test catches
|
|
||||||
Loads the full scene with `STRESS_MATADORS` (20) matadors, samples per-frame time
|
|
||||||
during normal play and during a mass-ragdoll spike, and fails if the average
|
|
||||||
exceeds 16 ms or any single frame exceeds 100 ms. Budgets are generous regression
|
|
||||||
guards (not a target frame rate); the measured avg/peak/fps print every run so a
|
|
||||||
gradual creep shows up before it trips the ceiling.
|
|
||||||
|
|
||||||
## Godot 4.x specifics
|
|
||||||
|
|
||||||
- `wrapf` / `wrap` instead of manual modulo for angles
|
|
||||||
- `lerp_angle` for smooth rotation interpolation (handles wrap-around correctly)
|
|
||||||
- `move_toward` for speed ramps without overshooting
|
|
||||||
- `PackedStringArray`, `PackedVector3Array`, etc. for performance-sensitive arrays
|
|
||||||
- `@tool` scripts for editor helpers only — don't use in gameplay scripts
|
|
||||||
- Resource (`extends Resource`) for shared data/config; no plain `Dictionary` for structured data
|
|
||||||
- `StringName` (`&"action_name"`) for input action lookups in hot paths
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
# Bullosseum — Game Design Document
|
|
||||||
|
|
||||||
> Living document. `?` marks open questions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Elevator Pitch
|
|
||||||
|
|
||||||
You are the bull. Waves of cowboys, matadors, and gladiators pour into your arena — each with their own tricks to dodge or counter your charge. Survive, wreck things, pick upgrades. Every run is different. Goofy low-poly mayhem.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Art Direction
|
|
||||||
|
|
||||||
- **Style:** Low-poly, chunky geometry, flat or minimal shading
|
|
||||||
- **Tone:** Goofy and physical — think *Gang Beasts* meets *Vampire Survivors* energy
|
|
||||||
- **Camera:** Third-person, spring-arm collision avoidance
|
|
||||||
- **Palette:** Warm sand/terracotta arena, vivid faction colours (red/gold for matadors, brown/blue for cowboys, iron/purple for gladiators)
|
|
||||||
- **Particles:** Stylised cartoon puffs — dust, impact sparks, ragdoll stars
|
|
||||||
- **Audio direction:** ?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Confirmed Mechanics
|
|
||||||
|
|
||||||
- Third-person bull locomotion with momentum (walk, charge)
|
|
||||||
- Wide committed turning arc during charge — charges feel like commitments
|
|
||||||
- Visual turn lag (body leans)
|
|
||||||
- Spring-arm camera with zoom
|
|
||||||
- Sand dust particles at hooves (walk = small, charge = big)
|
|
||||||
- Jolt Physics backend
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Run Structure (Vampire Survivors-inspired)
|
|
||||||
|
|
||||||
```
|
|
||||||
Start run → Wave spawns → Kill enemies → Wave cleared → Pick upgrade → Next wave → ... → Die or achieve target
|
|
||||||
```
|
|
||||||
|
|
||||||
- **No hard time limit per wave** — pressure comes from enemy density escalating
|
|
||||||
- **Upgrade picks between waves** — choose 1 of 3 options (stat, ability, passive synergy)
|
|
||||||
- **Fail state is soft** — getting overwhelmed is a death spiral, not a timeout. You stop being able to charge effectively and get cornered
|
|
||||||
- **Run length:** ? (15–25 minutes for a full run feels right for web)
|
|
||||||
- **Meta progression:** ? Unlock new starting abilities or bull cosmetics across runs
|
|
||||||
|
|
||||||
### Wave Escalation — Faction Ramp
|
|
||||||
|
|
||||||
Factions are introduced one at a time so each mechanic gets space to teach itself before mixing begins.
|
|
||||||
|
|
||||||
| Waves | Faction makeup | Boss |
|
|
||||||
|---|---|---|
|
|
||||||
| 1–3 | Matadors only | — |
|
|
||||||
| 4 | Matadors only | El Presidente |
|
|
||||||
| 5–7 | Matadors + Cowboys introduced | — |
|
|
||||||
| 8 | Mixed (matadors + cowboys) | Sheriff |
|
|
||||||
| 9–11 | All three factions introduced | — |
|
|
||||||
| 12 | Mixed (all three) | Centurion |
|
|
||||||
| 13+ | Full chaos — all factions, escalating density | Repeat bosses + ? final boss |
|
|
||||||
|
|
||||||
- Each new faction's first wave is a "pure" wave so the player can learn the pattern before it mixes
|
|
||||||
- Bosses appear solo — the arena clears before a boss spawns, making it feel like a duel
|
|
||||||
- ? After wave 12, does a loop begin (harder repeat) or does a final boss end the run?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Enemy Factions
|
|
||||||
|
|
||||||
### Matadors (the classics)
|
|
||||||
The trickiest faction — they're designed around baiting your charge.
|
|
||||||
|
|
||||||
| Enemy | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| Matador | Waves red cape to draw your charge, sidesteps at last moment. Must catch them off-guard or predict the dodge |
|
|
||||||
| Banderillero | Plants slow-flags in the ground — charges into one stuns the bull briefly |
|
|
||||||
| Picador | Mounted on horse, tanky, slow. Horse must be hit first before the rider is vulnerable |
|
|
||||||
| ? El Presidente (boss) | Summons matadors and teleports around the arena in bursts |
|
|
||||||
|
|
||||||
### Cowboys
|
|
||||||
The herd faction — individually easy, dangerous in groups.
|
|
||||||
|
|
||||||
| Enemy | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| Cowboy | Throws lasso to root you for 1–2s, then runs. Easy to hit, just annoying |
|
|
||||||
| Wrangler | Herds other enemies toward you — a force multiplier |
|
|
||||||
| Mounted Cowboy | Circles you on horseback, throwing dynamite. Must cut off the riding arc |
|
|
||||||
| ? Sheriff (boss) | Shotgun blast that staggers the bull; spawns deputies on death |
|
|
||||||
|
|
||||||
### Gladiators
|
|
||||||
The armour faction — charge angles matter.
|
|
||||||
|
|
||||||
| Enemy | Behaviour |
|
|
||||||
|---|---|
|
|
||||||
| Legionary | Front shield absorbs charges completely. Must hit from the side or behind |
|
|
||||||
| Net-thrower | Long-range root that holds you in place for 3s |
|
|
||||||
| Archer | Stationary on elevated platform (arena wall edge), fires slowing arrows |
|
|
||||||
| ? Centurion (boss) | Buffs nearby gladiators, has a charge-reflect shield that bounces you back |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ability System
|
|
||||||
|
|
||||||
Three slots total — always the same structure, filled differently every run.
|
|
||||||
|
|
||||||
```
|
|
||||||
[Charge ──────────] [Medium □] [Ultimate □]
|
|
||||||
always on 1 slot 1 slot
|
|
||||||
```
|
|
||||||
|
|
||||||
### Slot 1 — Charge (always on)
|
|
||||||
| | Description |
|
|
||||||
|---|---|
|
|
||||||
| **Charge** | Hold to build, release to sprint. Committed arc, hard to steer. The foundation everything else is built around |
|
|
||||||
|
|
||||||
### Slot 2 — Medium Ability
|
|
||||||
Cooldown ~8–15s. Tactical, reactive, fills gaps that charge can't. One equipped at a time.
|
|
||||||
|
|
||||||
| Ability | Description | Good against |
|
|
||||||
|---|---|---|
|
|
||||||
| **Stomp** | Slam front hooves — short-range AoE stun | Matador clusters, anything close |
|
|
||||||
| **Gore** | Upward headbutt — launches a single target | Shielded gladiators (bypasses front shield) |
|
|
||||||
| **Tail Whip** | 360° sweep behind and to the sides | Cowboys lassooing from behind |
|
|
||||||
| **Bull Roar** | Knockback shockwave — no damage, pure space creation | Net-throwers, tight groups |
|
|
||||||
| **Ground Pound** | From a jump, landing creates a shockwave crater | Mounted enemies (dismounts them) |
|
|
||||||
| **Counter** | Narrow window — if you charge as an attack lands, explosive ricochet | Picadors, centurion reflect |
|
|
||||||
|
|
||||||
### Slot 3 — Ultimate Ability
|
|
||||||
Cooldown ~35–50s, or requires a full charge meter. High impact. One equipped at a time.
|
|
||||||
|
|
||||||
| Ability | Description | Good against |
|
|
||||||
|---|---|---|
|
|
||||||
| **Stampede** | ~5s of zero deceleration — can't stop or steer well, flattens everything in the path | Boss phases, dense waves |
|
|
||||||
| **Enrage** | ~8s berserk: charge speed +60%, hitbox wider, ignores all slows and roots | Lasso/net-heavy waves |
|
|
||||||
| **Red Mist** | Every enemy on screen gets a red target marker and is pulled slightly toward you — brief guaranteed hits | Matador dodge phase |
|
|
||||||
| **Thunder Horn** | Next charge is electrified — chains damage to up to 4 nearby enemies on contact | Gladiator formations |
|
|
||||||
| **Call of the Herd** | Summon 3 ghost bulls that charge in parallel alongside you for 6s | Any — pure chaos |
|
|
||||||
|
|
||||||
### Card Pick Screen (between every wave)
|
|
||||||
Game pauses. Three cards shown — pick one.
|
|
||||||
|
|
||||||
| Card type | Examples |
|
|
||||||
|---|---|
|
|
||||||
| **New medium** | Swap current medium for a different one |
|
|
||||||
| **New ultimate** | Swap current ultimate for a different one |
|
|
||||||
| **Stat boost** | +charge speed, +turn rate, −medium cooldown, −ultimate cooldown |
|
|
||||||
| **Passive modifier** | Changes how your *current* ability behaves (see below) |
|
|
||||||
| **Synergy** | Unlocks a cross-ability combo effect |
|
|
||||||
|
|
||||||
**Passive modifier examples:**
|
|
||||||
- Stomp → "Dust Blind" — stun cloud also blocks enemy vision for 2s
|
|
||||||
- Gore → "Pinball" — launched enemy bounces off walls and damages others on landing
|
|
||||||
- Stampede → "Aftershock" — leaves a trembling ground trail that slows enemies for 5s
|
|
||||||
- Enrage → "Bloodlust" — kills during enrage reduce remaining cooldown
|
|
||||||
|
|
||||||
**Synergy examples:**
|
|
||||||
- Charge + Stomp = "Seismic Run" — charging for 2s+ turns your next stomp into a ground wave
|
|
||||||
- Gore + Thunder Horn = "Lightning Rod" — electrified gore sends a chain to the whole group
|
|
||||||
- ? Others to discover during design
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Arena Design
|
|
||||||
|
|
||||||
Each faction maps to an arena environment. Mixed-faction waves could transition the arena or blend themes.
|
|
||||||
|
|
||||||
| Faction | Arena | Hazards |
|
|
||||||
|---|---|---|
|
|
||||||
| Matadors | Sand bullring, wooden barrier ring | Flags slow movement, open centre is the danger zone |
|
|
||||||
| Cowboys | Frontier rodeo grounds, hay bales, fences | Lasso posts (rope traps), horse stampede lanes |
|
|
||||||
| Gladiators | Roman colosseum, stone floor, iron gates | Archer towers on walls, spike traps, wet stone (slide) |
|
|
||||||
|
|
||||||
- ? Does the arena change mid-run or is one arena selected at run start?
|
|
||||||
- ? Destructible fence/barrier sections throughout?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Loop Revisited
|
|
||||||
|
|
||||||
```
|
|
||||||
Move → Spot threat pattern → Position approach angle → Charge/Ability → Chain hits → Wave clear
|
|
||||||
↑ ↓
|
|
||||||
Dodge/react to debuffs (lasso, net, slow-flag) Pick upgrade (1 of 3)
|
|
||||||
```
|
|
||||||
|
|
||||||
The **positioning** step is where skill expression lives: which enemy to hit first, what angle to approach a shielded gladiator, whether to charge the matador or fake him out.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **Meta progression** — cosmetic unlocks only, or do you unlock new starting medium/ultimate options across runs?
|
|
||||||
2. **Arena change** — single arena per run, or does the arena theme shift when a new faction is introduced mid-run?
|
|
||||||
3. **After wave 12** — infinite loop with scaling difficulty, or a final boss that ends the run cleanly?
|
|
||||||
4. **Ability replacement** — when you pick a new medium/ultimate card, does it always replace the slot, or can you upgrade an existing ability through repeated picks (level 1→2→3)?
|
|
||||||
5. **Web scope** — vertical slice (matadors only, 4 waves, one ability tree) or full 12-wave game?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- *Vampire Survivors* — wave escalation, upgrade pick loop, run length
|
|
||||||
- *Gang Beasts* — ragdoll slapstick commitment
|
|
||||||
- *Katamari* / *Donut County* — simple core verb scaled through variety
|
|
||||||
- *Superhot* — charge-as-commitment, every move mattering
|
|
||||||
- *Cult of the Lamb* — faction variety with distinct visual languages
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[InternetShortcut]
|
||||||
|
URL=https://www.youtube.com/watch?v=ZCb12AHKMfE
|
||||||
-1098
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
uid://cn7fhjkro7637
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
[gd_scene format=3 uid="uid://o8ocjfprxa7u"]
|
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://cn7fhjkro7637" path="res://actors/bear/bear.gd" id="1_beargd"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://cofv5dbavesis" path="res://art/characters/bear.glb" id="2_bearglb"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://bdbx0wwa73rhp" path="res://art/fx/bear_fx.glb" id="3_bearfx"]
|
|
||||||
[ext_resource type="Material" uid="uid://blpkmyikjnhbo" path="res://art/materials/bear_claw_fx.tres" id="4_clawfx"]
|
|
||||||
[ext_resource type="PackedScene" path="res://actors/bear/bear_attack_hitboxes.tscn" id="5_hitboxes"]
|
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
|
|
||||||
radius = 1.0
|
|
||||||
height = 2.4
|
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
|
|
||||||
radius = 1.3
|
|
||||||
height = 2.7
|
|
||||||
|
|
||||||
[node name="Bear" type="CharacterBody3D" unique_id=1573237287]
|
|
||||||
script = ExtResource("1_beargd")
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=830443382]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
|
|
||||||
shape = SubResource("CapsuleShape3D_body")
|
|
||||||
|
|
||||||
[node name="HitArea" type="Area3D" parent="." unique_id=365327397]
|
|
||||||
monitorable = false
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea" unique_id=2111867348]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.35, 0)
|
|
||||||
shape = SubResource("CapsuleShape3D_hit")
|
|
||||||
|
|
||||||
[node name="bear_model" parent="." unique_id=359217823 instance=ExtResource("2_bearglb")]
|
|
||||||
|
|
||||||
[node name="Bear_FX" parent="bear_model" unique_id=2070553519 instance=ExtResource("3_bearfx")]
|
|
||||||
|
|
||||||
[node name="Bear_fx_claw_attack_R" parent="bear_model/Bear_FX" index="2" unique_id=1939248432]
|
|
||||||
surface_material_override/0 = ExtResource("4_clawfx")
|
|
||||||
|
|
||||||
[node name="Bear_fx_claw_attack_L" parent="bear_model/Bear_FX" index="3" unique_id=834640978]
|
|
||||||
surface_material_override/0 = ExtResource("4_clawfx")
|
|
||||||
|
|
||||||
[node name="AttackHitboxes" parent="bear_model" instance=ExtResource("5_hitboxes")]
|
|
||||||
|
|
||||||
[editable path="bear_model"]
|
|
||||||
[editable path="bear_model/Bear_FX"]
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
[gd_scene format=3]
|
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://systems/combat/hitbox.gd" id="1_qxn4a"]
|
|
||||||
|
|
||||||
[sub_resource type="CylinderShape3D" id="CylinderShape3D_6dow8"]
|
|
||||||
height = 3.0
|
|
||||||
radius = 4.0
|
|
||||||
|
|
||||||
[sub_resource type="BoxShape3D" id="BoxShape3D_tmmfg"]
|
|
||||||
size = Vector3(2.6, 2.5, 20)
|
|
||||||
|
|
||||||
[sub_resource type="Animation" id="Animation_wcwtv"]
|
|
||||||
length = 0.25
|
|
||||||
tracks/0/type = "scale_3d"
|
|
||||||
tracks/0/imported = false
|
|
||||||
tracks/0/enabled = true
|
|
||||||
tracks/0/path = NodePath("LeapSlamHitbox/CollisionShape3D")
|
|
||||||
tracks/0/interp = 1
|
|
||||||
tracks/0/loop_wrap = true
|
|
||||||
tracks/0/keys = PackedFloat32Array(0, 1, 0.12, 1, 0.12, 0.06, 1, 1, 1, 1, 0.25, 1, 1, 1, 1)
|
|
||||||
|
|
||||||
[sub_resource type="Animation" id="Animation_bg5u7"]
|
|
||||||
length = 0.3
|
|
||||||
tracks/0/type = "scale_3d"
|
|
||||||
tracks/0/imported = false
|
|
||||||
tracks/0/enabled = true
|
|
||||||
tracks/0/path = NodePath("SmashHitbox/CollisionShape3D")
|
|
||||||
tracks/0/interp = 1
|
|
||||||
tracks/0/loop_wrap = true
|
|
||||||
tracks/0/keys = PackedFloat32Array(0, 1, 1, 1, 0.02, 0.1, 1, 1, 1, 1, 0.3, 1, 1, 1, 1)
|
|
||||||
tracks/1/type = "position_3d"
|
|
||||||
tracks/1/imported = false
|
|
||||||
tracks/1/enabled = true
|
|
||||||
tracks/1/path = NodePath("SmashHitbox/CollisionShape3D")
|
|
||||||
tracks/1/interp = 1
|
|
||||||
tracks/1/loop_wrap = true
|
|
||||||
tracks/1/keys = PackedFloat32Array(0, 1, 0, 0.9, 0.2, 0.1, 1, 0, 0.9, 10, 0.3, 1, 0, 0.9, 10)
|
|
||||||
|
|
||||||
[sub_resource type="AnimationLibrary" id="AnimationLibrary_u1id2"]
|
|
||||||
_data = {
|
|
||||||
&"leap_slam": SubResource("Animation_wcwtv"),
|
|
||||||
&"smash_travel": SubResource("Animation_bg5u7")
|
|
||||||
}
|
|
||||||
|
|
||||||
[node name="AttackHitboxes" type="Node3D" unique_id=2010404706]
|
|
||||||
|
|
||||||
[node name="LeapSlamHitbox" type="Area3D" parent="." unique_id=1751893032]
|
|
||||||
collision_layer = 0
|
|
||||||
collision_mask = 2
|
|
||||||
monitoring = false
|
|
||||||
monitorable = false
|
|
||||||
script = ExtResource("1_qxn4a")
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="LeapSlamHitbox" unique_id=1320172052]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
|
|
||||||
shape = SubResource("CylinderShape3D_6dow8")
|
|
||||||
|
|
||||||
[node name="SmashHitbox" type="Area3D" parent="." unique_id=1579648726]
|
|
||||||
collision_layer = 0
|
|
||||||
collision_mask = 2
|
|
||||||
monitoring = false
|
|
||||||
monitorable = false
|
|
||||||
script = ExtResource("1_qxn4a")
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="SmashHitbox" unique_id=528637962]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 10)
|
|
||||||
shape = SubResource("BoxShape3D_tmmfg")
|
|
||||||
|
|
||||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=2139876757]
|
|
||||||
libraries/ = SubResource("AnimationLibrary_u1id2")
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
uid://bvfa7xi0luysk
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[gd_scene format=3 uid="uid://d0te4mwbh33ag"]
|
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://bvfa7xi0luysk" path="res://actors/matador/matador.gd" id="1_matgd"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://eu2lqjhowj4t" path="res://art/characters/matador.glb" id="2_matfbx"]
|
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
|
|
||||||
radius = 0.25
|
|
||||||
height = 1.2
|
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
|
|
||||||
radius = 0.45
|
|
||||||
height = 1.4
|
|
||||||
|
|
||||||
[node name="Matador" type="CharacterBody3D" unique_id=1781857630]
|
|
||||||
script = ExtResource("1_matgd")
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=2101651042]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
|
|
||||||
shape = SubResource("CapsuleShape3D_body")
|
|
||||||
|
|
||||||
[node name="HitArea" type="Area3D" parent="." unique_id=745037545]
|
|
||||||
monitorable = false
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea" unique_id=1497356275]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
|
|
||||||
shape = SubResource("CapsuleShape3D_hit")
|
|
||||||
|
|
||||||
[node name="matador2" parent="." unique_id=1487256381 instance=ExtResource("2_matfbx")]
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
class_name MatadorRagdoll
|
|
||||||
extends RefCounted
|
|
||||||
## Builds the matador's physical-bone ragdoll. Body joints are cone-twist with
|
|
||||||
## limited swing/twist (DP-tunable) so the corpse keeps its silhouette instead of
|
|
||||||
## folding into a flat "deflated" pile; the cape stays free (PIN) so it drapes.
|
|
||||||
|
|
||||||
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
|
|
||||||
|
|
||||||
# [bone, radius] → sphere collider; [bone, radius, height] → capsule.
|
|
||||||
const _BONES: Array = [
|
|
||||||
["matador", 0.18],
|
|
||||||
["COG", 0.12],
|
|
||||||
["chest", 0.13, 0.30],
|
|
||||||
["head", 0.14],
|
|
||||||
["collarbone_L", 0.06, 0.18],
|
|
||||||
["collarbone_R", 0.06, 0.18],
|
|
||||||
["arm_L", 0.07, 0.22],
|
|
||||||
["arm_R", 0.07, 0.22],
|
|
||||||
["forearm_L", 0.06, 0.22],
|
|
||||||
["forearm_R", 0.06, 0.22],
|
|
||||||
["hand_L", 0.05, 0.12],
|
|
||||||
["hand_R", 0.05, 0.12],
|
|
||||||
["leg_L", 0.10, 0.32],
|
|
||||||
["leg_R", 0.10, 0.32],
|
|
||||||
["shin_L", 0.08, 0.28],
|
|
||||||
["shin_R", 0.08, 0.28],
|
|
||||||
["foot_L", 0.07, 0.18],
|
|
||||||
["foot_R", 0.07, 0.18],
|
|
||||||
["Bone", 0.04, 0.30],
|
|
||||||
["Bone.001", 0.04, 0.34],
|
|
||||||
["Bone.002", 0.04, 0.30],
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Reuse an existing simulator if the rig already has one, else build the bodies.
|
|
||||||
static func build(skeleton: Skeleton3D) -> PhysicalBoneSimulator3D:
|
|
||||||
for child: Node in skeleton.get_children():
|
|
||||||
if child is PhysicalBoneSimulator3D:
|
|
||||||
(child as PhysicalBoneSimulator3D).active = false
|
|
||||||
return child as PhysicalBoneSimulator3D
|
|
||||||
|
|
||||||
var sim := PhysicalBoneSimulator3D.new()
|
|
||||||
sim.active = false
|
|
||||||
skeleton.add_child(sim)
|
|
||||||
|
|
||||||
var swing := DP.f("mat_joint_swing")
|
|
||||||
var twist := DP.f("mat_joint_twist")
|
|
||||||
var damp := DP.f("mat_ragdoll_damp")
|
|
||||||
|
|
||||||
for entry: Array in _BONES:
|
|
||||||
var bname: String = entry[0]
|
|
||||||
if skeleton.find_bone(bname) == -1:
|
|
||||||
continue
|
|
||||||
var pb := PhysicalBone3D.new()
|
|
||||||
pb.bone_name = bname
|
|
||||||
pb.mass = 0.4
|
|
||||||
pb.linear_damp = 0.1
|
|
||||||
# The ragdoll only simulates once the matador is dead, so it lives on the CORPSE
|
|
||||||
# layer and masks to CORPSE alone: it rests on the ground/walls (which also carry
|
|
||||||
# CORPSE) but the bull — which collides only with WORLD — passes straight through
|
|
||||||
# the bodies instead of tripping over them.
|
|
||||||
pb.collision_layer = PhysicsLayers.CORPSE
|
|
||||||
pb.collision_mask = PhysicsLayers.CORPSE
|
|
||||||
if bname.begins_with("Bone"):
|
|
||||||
# Cape: floppy cloth — free swing, light, draggy, one-way collision (layer 0
|
|
||||||
# so nothing collides with it; it still drapes on the CORPSE-masked ground).
|
|
||||||
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
|
|
||||||
pb.mass = 0.15
|
|
||||||
pb.linear_damp = 0.5
|
|
||||||
pb.angular_damp = 0.7
|
|
||||||
pb.collision_layer = 0
|
|
||||||
else:
|
|
||||||
# Body: cone-twist joints cap how far each bone bends from its rest
|
|
||||||
# pose, so the silhouette holds; extra angular damp settles the flail.
|
|
||||||
pb.joint_type = PhysicalBone3D.JOINT_TYPE_CONE
|
|
||||||
pb.angular_damp = damp
|
|
||||||
pb.set(&"joint_constraints/swing_span", swing)
|
|
||||||
pb.set(&"joint_constraints/twist_span", twist)
|
|
||||||
var cs := CollisionShape3D.new()
|
|
||||||
if entry.size() >= 3:
|
|
||||||
var cap := CapsuleShape3D.new()
|
|
||||||
cap.radius = entry[1]
|
|
||||||
cap.height = entry[2]
|
|
||||||
cs.shape = cap
|
|
||||||
else:
|
|
||||||
var sph := SphereShape3D.new()
|
|
||||||
sph.radius = entry[1]
|
|
||||||
cs.shape = sph
|
|
||||||
pb.add_child(cs)
|
|
||||||
sim.add_child(pb)
|
|
||||||
|
|
||||||
return sim
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dmf04b5exnejn
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[gd_scene format=3 uid="uid://c8spearwrap01"]
|
|
||||||
|
|
||||||
[ext_resource type="PackedScene" uid="uid://jepc74w8opbu" path="res://art/weapons/spear.glb" id="1_spearglb"]
|
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_shaft"]
|
|
||||||
radius = 0.13
|
|
||||||
height = 2.2
|
|
||||||
|
|
||||||
[node name="Spear" type="Node3D"]
|
|
||||||
|
|
||||||
[node name="spear" parent="." instance=ExtResource("1_spearglb")]
|
|
||||||
|
|
||||||
[node name="HitShape" type="CollisionShape3D" parent="."]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0.79)
|
|
||||||
shape = SubResource("CapsuleShape3D_shaft")
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
class_name SpearProjectile
|
|
||||||
extends RigidBody3D
|
|
||||||
|
|
||||||
# A thrown spear. It flies a ballistic arc (gravity does the work — the launch
|
|
||||||
# velocity is solved by the matador to land on the bull) and keeps its shaft aimed
|
|
||||||
# along its own velocity, so it noses over at the apex and drives in point-first.
|
|
||||||
# On impact it embeds where it struck: the tip buried just past the contact point
|
|
||||||
# with the shaft trailing back along the flight line, so it reads as stuck-in rather
|
|
||||||
# than floating alongside. A hit on the bull leaves the spear riding it like a spine
|
|
||||||
# (see shed_all, which the bull's slam calls to fling them all off); other hits pin
|
|
||||||
# it in place until the despawn timer clears it.
|
|
||||||
|
|
||||||
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
|
|
||||||
|
|
||||||
const _DESPAWN_SEC: float = 8.0
|
|
||||||
const _STUCK_GROUP: StringName = &"stuck_spear"
|
|
||||||
# Shaft geometry in spear.glb local space: tip at +Z, butt behind the origin.
|
|
||||||
const _TIP_Z: float = 1.915
|
|
||||||
const _EMBED_DEPTH: float = 0.72 # how far past the surface the tip buries
|
|
||||||
# Bone the bull's spears ride on. The collision spheres sit outside the hide, so a
|
|
||||||
# spear stuck to the body root reads as floating; anchoring to COG also lets it bob
|
|
||||||
# and rotate with the animated body instead of only the character root.
|
|
||||||
const _COG_BONE: String = "COG"
|
|
||||||
const _ANCHOR_NAME: String = "SpearAnchor"
|
|
||||||
|
|
||||||
var _mesh: Node3D = null
|
|
||||||
var _stopped: bool = false
|
|
||||||
var _flight_dir: Vector3 = Vector3.ZERO # last pre-impact travel direction
|
|
||||||
|
|
||||||
|
|
||||||
# Build, seat and launch a spear from an already-instantiated mesh (the one that was
|
|
||||||
# in the matador's hand). `origin` is the release point; `velocity` is the full
|
|
||||||
# ballistic launch vector; `gravity` is the RigidBody gravity_scale for the arc.
|
|
||||||
static func launch(parent: Node, mesh: Node3D, origin: Vector3, velocity: Vector3,
|
|
||||||
gravity: float) -> SpearProjectile:
|
|
||||||
var s := SpearProjectile.new()
|
|
||||||
s.gravity_scale = gravity
|
|
||||||
s.collision_layer = 0
|
|
||||||
# Mask WORLD so a miss sticks in the arena, and BULL so it can actually hit the
|
|
||||||
# bull — the bull's body is on layer BULL, not WORLD, so a WORLD-only mask flew
|
|
||||||
# straight through it (every throw whiffed, even against a stationary bull).
|
|
||||||
s.collision_mask = PhysicsLayers.WORLD | PhysicsLayers.BULL
|
|
||||||
s.contact_monitor = true
|
|
||||||
s.max_contacts_reported = 6
|
|
||||||
parent.add_child(s)
|
|
||||||
# Seat the body so its local +Z (the shaft, see spear.glb) points along the throw.
|
|
||||||
s.global_transform = Transform3D(_aim_basis(velocity), origin)
|
|
||||||
# Take the editor-tweakable collider from the spear wrapper (Spear.tscn's HitShape)
|
|
||||||
# before reparenting, then drop the now-nested template.
|
|
||||||
var collider := _collider_from(mesh)
|
|
||||||
mesh.reparent(s, false)
|
|
||||||
mesh.transform = Transform3D.IDENTITY
|
|
||||||
s._mesh = mesh
|
|
||||||
var tmpl := mesh.get_node_or_null("HitShape")
|
|
||||||
if tmpl != null:
|
|
||||||
tmpl.queue_free()
|
|
||||||
s.add_child(collider)
|
|
||||||
s.linear_velocity = velocity
|
|
||||||
_despawn(s)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
# Shed every spear stuck in `bull` — pop each off as a tumbling body that flies clear
|
|
||||||
# and clears itself on the despawn timer. Called by the bull's slam.
|
|
||||||
static func shed_all(bull: Node) -> void:
|
|
||||||
if not (bull is Node3D):
|
|
||||||
return
|
|
||||||
var scene: Node = bull.get_tree().current_scene
|
|
||||||
if scene == null:
|
|
||||||
scene = bull
|
|
||||||
var center: Vector3 = (bull as Node3D).global_position
|
|
||||||
# Stuck spears now live under a BoneAttachment3D deep in the skeleton, so walk the
|
|
||||||
# whole subtree by group rather than only the bull's direct children.
|
|
||||||
for node in bull.get_tree().get_nodes_in_group(_STUCK_GROUP):
|
|
||||||
if not (bull as Node).is_ancestor_of(node):
|
|
||||||
continue
|
|
||||||
var mesh := node as Node3D
|
|
||||||
var gx := mesh.global_transform
|
|
||||||
var rb := RigidBody3D.new()
|
|
||||||
rb.collision_layer = 0
|
|
||||||
rb.collision_mask = 1
|
|
||||||
scene.add_child(rb)
|
|
||||||
rb.global_transform = gx
|
|
||||||
mesh.reparent(rb, false)
|
|
||||||
mesh.transform = Transform3D.IDENTITY
|
|
||||||
mesh.remove_from_group(_STUCK_GROUP)
|
|
||||||
rb.add_child(_shaft_collider())
|
|
||||||
# Fling outward from the bull with a bit of lift and spin.
|
|
||||||
var out := gx.origin - center
|
|
||||||
out.y = 0.0
|
|
||||||
out = out.normalized() if out.length() > 0.01 else Vector3(randf() - 0.5, 0.0, randf() - 0.5)
|
|
||||||
rb.linear_velocity = out * randf_range(3.0, 6.0) + Vector3.UP * randf_range(3.0, 5.0)
|
|
||||||
rb.angular_velocity = Vector3(randf_range(-10.0, 10.0),
|
|
||||||
randf_range(-10.0, 10.0), randf_range(-10.0, 10.0))
|
|
||||||
_despawn(rb)
|
|
||||||
|
|
||||||
|
|
||||||
# Clone the wrapper's editor-tweakable HitShape (Spear.tscn) into a fresh CollisionShape3D
|
|
||||||
# for the projectile body; falls back to the built-in shaft collider if it's missing.
|
|
||||||
static func _collider_from(source: Node) -> CollisionShape3D:
|
|
||||||
var tmpl := source.get_node_or_null("HitShape") as CollisionShape3D
|
|
||||||
if tmpl != null and tmpl.shape != null:
|
|
||||||
var cs := CollisionShape3D.new()
|
|
||||||
cs.shape = tmpl.shape
|
|
||||||
cs.transform = tmpl.transform
|
|
||||||
return cs
|
|
||||||
return _shaft_collider()
|
|
||||||
|
|
||||||
|
|
||||||
static func _shaft_collider() -> CollisionShape3D:
|
|
||||||
# Collider along local +Z (mesh runs z≈-0.34 … 1.92), fattened so a fast throw
|
|
||||||
# can't tunnel past the bull's collision spheres.
|
|
||||||
var cap := CapsuleShape3D.new()
|
|
||||||
cap.radius = 0.13
|
|
||||||
cap.height = 2.2
|
|
||||||
var cs := CollisionShape3D.new()
|
|
||||||
cs.shape = cap
|
|
||||||
cs.position = Vector3(0.0, 0.0, 0.79)
|
|
||||||
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
|
|
||||||
return cs
|
|
||||||
|
|
||||||
|
|
||||||
# Continuous bleed from a spear lodged in the bull: a steady spurt of dark-red gobs out
|
|
||||||
# of the entry wound. Parented to the stuck mesh so it tracks the wound as the bull
|
|
||||||
# moves; world-space particles (local_coords off) so they arc off and fall behind as a
|
|
||||||
# trail. Dies with the mesh when the spear is shed or despawned.
|
|
||||||
static func _wound_emitter() -> CPUParticles3D:
|
|
||||||
var ramp := Gradient.new()
|
|
||||||
ramp.set_color(0, Color(0.5, 0.02, 0.02, 1.0))
|
|
||||||
ramp.set_color(1, Color(0.22, 0.0, 0.0, 0.0))
|
|
||||||
|
|
||||||
var sc := Curve.new()
|
|
||||||
sc.add_point(Vector2(0.0, 1.0))
|
|
||||||
sc.add_point(Vector2(0.6, 0.7))
|
|
||||||
sc.add_point(Vector2(1.0, 0.0))
|
|
||||||
|
|
||||||
var p := CPUParticles3D.new()
|
|
||||||
# Seat at the wound (where the shaft meets the hide) and spray back out along the
|
|
||||||
# shaft: mesh local -Z is the exit direction, rotated into world by the node basis.
|
|
||||||
p.position = Vector3(0.0, 0.0, _TIP_Z - _EMBED_DEPTH)
|
|
||||||
p.emitting = true
|
|
||||||
p.amount = 22
|
|
||||||
p.lifetime = 0.6
|
|
||||||
p.explosiveness = 0.0
|
|
||||||
p.randomness = 0.6
|
|
||||||
p.local_coords = false
|
|
||||||
p.direction = Vector3(0.0, 0.55, -1.0).normalized()
|
|
||||||
p.spread = 32.0
|
|
||||||
p.gravity = Vector3(0.0, -12.0, 0.0)
|
|
||||||
p.initial_velocity_min = 1.6
|
|
||||||
p.initial_velocity_max = 4.0
|
|
||||||
p.scale_amount_min = 0.5
|
|
||||||
p.scale_amount_max = 1.4
|
|
||||||
p.mesh = _blood_mesh()
|
|
||||||
p.color_ramp = ramp
|
|
||||||
p.scale_amount_curve = sc
|
|
||||||
return p
|
|
||||||
|
|
||||||
|
|
||||||
# Small unshaded, vertex-coloured sphere for blood gobs.
|
|
||||||
static func _blood_mesh() -> SphereMesh:
|
|
||||||
var mat := StandardMaterial3D.new()
|
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
||||||
mat.vertex_color_use_as_albedo = true
|
|
||||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
||||||
var s := SphereMesh.new()
|
|
||||||
s.radius = 0.045
|
|
||||||
s.height = 0.09
|
|
||||||
s.radial_segments = 4
|
|
||||||
s.rings = 2
|
|
||||||
s.material = mat
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
static func _despawn(node: Node) -> void:
|
|
||||||
node.get_tree().create_timer(_DESPAWN_SEC).timeout.connect(func() -> void:
|
|
||||||
if is_instance_valid(node):
|
|
||||||
node.queue_free()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Basis whose local +Z points along `v` (looking_at points -Z at its target).
|
|
||||||
static func _aim_basis(v: Vector3) -> Basis:
|
|
||||||
var dir := v.normalized()
|
|
||||||
if dir.length_squared() < 0.0001:
|
|
||||||
return Basis.IDENTITY
|
|
||||||
var up := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
|
||||||
return Basis.looking_at(-dir, up)
|
|
||||||
|
|
||||||
|
|
||||||
# While flying, keep the shaft aligned with the velocity; on the first contact freeze
|
|
||||||
# and hand off to _embed so the spear sticks where it struck instead of bouncing off.
|
|
||||||
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
|
||||||
if _stopped:
|
|
||||||
return
|
|
||||||
if state.get_contact_count() > 0:
|
|
||||||
_stopped = true
|
|
||||||
# Use the remembered pre-impact heading — state.linear_velocity is already the
|
|
||||||
# bounced (post-collision) velocity by now, which would aim the shaft wrong.
|
|
||||||
var contact := state.get_contact_collider_position(0)
|
|
||||||
var other := state.get_contact_collider_object(0)
|
|
||||||
state.linear_velocity = Vector3.ZERO
|
|
||||||
state.angular_velocity = Vector3.ZERO
|
|
||||||
_embed.call_deferred(other, contact, _flight_dir)
|
|
||||||
return
|
|
||||||
var v := state.linear_velocity
|
|
||||||
if v.length_squared() > 0.04:
|
|
||||||
_flight_dir = v.normalized()
|
|
||||||
var t := state.transform
|
|
||||||
t.basis = _aim_basis(v)
|
|
||||||
state.transform = t
|
|
||||||
state.angular_velocity = Vector3.ZERO
|
|
||||||
|
|
||||||
|
|
||||||
# Compose the stuck pose (tip buried _EMBED_DEPTH past the contact, shaft along the
|
|
||||||
# flight line) and plant the spear. A bull hit hands the mesh to the bull so it rides
|
|
||||||
# along as a spine; anything else pins the body in place for the despawn timer.
|
|
||||||
func _embed(other: Object, contact: Vector3, flight: Vector3) -> void:
|
|
||||||
var f := flight.normalized()
|
|
||||||
if f.length_squared() < 0.0001:
|
|
||||||
f = global_transform.basis.z.normalized()
|
|
||||||
var pose := Transform3D(_aim_basis(f), contact + f * (_EMBED_DEPTH - _TIP_Z))
|
|
||||||
var bull := other as Node
|
|
||||||
if bull != null and bull.is_in_group(&"player"):
|
|
||||||
DamageEvent.deal(bull, DamageEvent.Cause.THROWN, contact, self)
|
|
||||||
if is_instance_valid(_mesh) and is_instance_valid(bull):
|
|
||||||
_mesh.reparent(_spear_anchor(bull), false)
|
|
||||||
_mesh.global_transform = pose
|
|
||||||
_mesh.add_to_group(_STUCK_GROUP)
|
|
||||||
if Settings.gore:
|
|
||||||
_mesh.add_child(_wound_emitter()) # keep bleeding from the wound while lodged
|
|
||||||
queue_free()
|
|
||||||
return
|
|
||||||
freeze = true
|
|
||||||
global_transform = pose
|
|
||||||
|
|
||||||
|
|
||||||
# Node embedded spears ride on the bull: a BoneAttachment3D pinned to the COG bone so
|
|
||||||
# they follow the animated body (bob, turn) rather than only the character root. One
|
|
||||||
# anchor is shared by every spear; falls back to the bull root if there's no skeleton.
|
|
||||||
static func _spear_anchor(bull: Node) -> Node3D:
|
|
||||||
var existing := bull.find_child(_ANCHOR_NAME, true, false)
|
|
||||||
if existing is Node3D:
|
|
||||||
return existing as Node3D
|
|
||||||
var skel := bull.find_child("Skeleton3D", true, false) as Skeleton3D
|
|
||||||
if skel == null:
|
|
||||||
return bull as Node3D
|
|
||||||
var ba := BoneAttachment3D.new()
|
|
||||||
ba.name = _ANCHOR_NAME
|
|
||||||
skel.add_child(ba)
|
|
||||||
ba.bone_name = _COG_BONE
|
|
||||||
return ba
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://dwjakh1is1jtj
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
[gd_scene format=3 uid="uid://c8swordwrap01"]
|
|
||||||
|
|
||||||
[ext_resource type="PackedScene" uid="uid://xyvank512l5n" path="res://art/weapons/sword.glb" id="1_swordglb"]
|
|
||||||
[ext_resource type="Script" path="res://systems/combat/hitbox.gd" id="2_hitbox"]
|
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_blade"]
|
|
||||||
radius = 0.18
|
|
||||||
height = 1.3
|
|
||||||
|
|
||||||
[node name="Sword" type="Node3D"]
|
|
||||||
|
|
||||||
[node name="sword" parent="." instance=ExtResource("1_swordglb")]
|
|
||||||
|
|
||||||
[node name="BladeHitbox" type="Area3D" parent="."]
|
|
||||||
script = ExtResource("2_hitbox")
|
|
||||||
damage_cause = "gored"
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="BladeHitbox"]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0.8)
|
|
||||||
shape = SubResource("CapsuleShape3D_blade")
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
extends Node
|
|
||||||
## Procedural (non-particle) bull impact FX: the expanding ground-slam shockwave rings and the
|
|
||||||
## kick flash-pop. These are tween-animated MeshInstance3D flashes, not CPUParticles3D, so they
|
|
||||||
## can't be authored as systems/fx/ scenes and live in code here. Created by player.gd; the
|
|
||||||
## transient meshes are parented into the level (the bull's parent) so they hold their world
|
|
||||||
## position while the bull keeps moving.
|
|
||||||
|
|
||||||
var _player: Node3D
|
|
||||||
var _ring_mesh: ArrayMesh = null
|
|
||||||
|
|
||||||
|
|
||||||
func setup(player: Node3D) -> void:
|
|
||||||
_player = player
|
|
||||||
|
|
||||||
|
|
||||||
# Three nested shockwave rings racing outward from a ground slam, each fading as it grows.
|
|
||||||
func slam_rings(origin: Vector3, max_r: float) -> void:
|
|
||||||
if _ring_mesh == null:
|
|
||||||
_ring_mesh = _make_ring_mesh()
|
|
||||||
_launch_ring(origin, max_r, 0.00, 0.55, Color(1.0, 0.70, 0.10, 0.85))
|
|
||||||
_launch_ring(origin, max_r, 0.10, 0.52, Color(1.0, 0.50, 0.10, 0.65))
|
|
||||||
_launch_ring(origin, max_r, 0.20, 0.48, Color(0.9, 0.30, 0.10, 0.50))
|
|
||||||
|
|
||||||
|
|
||||||
# A quick bright flash-pop at the kick's contact point.
|
|
||||||
func kick_flash(origin: Vector3) -> void:
|
|
||||||
var mat := _flat_material(Color(1.0, 0.97, 0.40, 0.0))
|
|
||||||
var sphere := SphereMesh.new()
|
|
||||||
sphere.radius = 1.0
|
|
||||||
sphere.height = 2.0
|
|
||||||
sphere.radial_segments = 8
|
|
||||||
sphere.rings = 4
|
|
||||||
sphere.material = mat
|
|
||||||
|
|
||||||
var mi := MeshInstance3D.new()
|
|
||||||
mi.mesh = sphere
|
|
||||||
mi.material_override = mat
|
|
||||||
mi.scale = Vector3(0.05, 0.05, 0.05)
|
|
||||||
_host().add_child(mi)
|
|
||||||
mi.global_position = origin
|
|
||||||
|
|
||||||
var tween := create_tween()
|
|
||||||
tween.set_parallel(true)
|
|
||||||
tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\
|
|
||||||
.set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
|
|
||||||
var fade := func(a: float) -> void: mat.albedo_color.a = a
|
|
||||||
tween.tween_method(fade, 0.92, 0.0, 0.14)\
|
|
||||||
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
|
|
||||||
tween.tween_callback(mi.queue_free).set_delay(0.16)
|
|
||||||
|
|
||||||
|
|
||||||
func _launch_ring(origin: Vector3, max_r: float, delay: float, duration: float, col: Color) -> void:
|
|
||||||
var mat := _flat_material(Color(col.r, col.g, col.b, 0.0))
|
|
||||||
var mi := MeshInstance3D.new()
|
|
||||||
mi.mesh = _ring_mesh
|
|
||||||
mi.material_override = mat
|
|
||||||
mi.scale = Vector3(0.02, 1.0, 0.02)
|
|
||||||
_host().add_child(mi)
|
|
||||||
mi.global_position = origin
|
|
||||||
|
|
||||||
var tween := create_tween()
|
|
||||||
tween.set_parallel(true)
|
|
||||||
tween.tween_property(mi, "scale", Vector3(max_r, 1.0, max_r), duration)\
|
|
||||||
.set_delay(delay).set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
|
|
||||||
var fade := func(a: float) -> void: mat.albedo_color.a = a
|
|
||||||
tween.tween_method(fade, col.a, 0.0, duration)\
|
|
||||||
.set_delay(delay).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
|
|
||||||
tween.tween_callback(mi.queue_free).set_delay(delay + duration + 0.05)
|
|
||||||
|
|
||||||
|
|
||||||
func _flat_material(color: Color) -> StandardMaterial3D:
|
|
||||||
var mat := StandardMaterial3D.new()
|
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
||||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
||||||
mat.albedo_color = color
|
|
||||||
return mat
|
|
||||||
|
|
||||||
|
|
||||||
# A flat annulus in the XZ plane (unit radius), scaled up at runtime into the shockwave ring.
|
|
||||||
func _make_ring_mesh() -> ArrayMesh:
|
|
||||||
const INNER_R: float = 0.82
|
|
||||||
const OUTER_R: float = 1.00
|
|
||||||
const SEGMENTS: int = 48
|
|
||||||
|
|
||||||
var verts := PackedVector3Array()
|
|
||||||
var indices := PackedInt32Array()
|
|
||||||
for i: int in SEGMENTS:
|
|
||||||
var a0 := i / float(SEGMENTS) * TAU
|
|
||||||
var a1 := (i + 1) / float(SEGMENTS) * TAU
|
|
||||||
var vi := i * 4
|
|
||||||
verts.append(Vector3(cos(a0) * INNER_R, 0.0, sin(a0) * INNER_R))
|
|
||||||
verts.append(Vector3(cos(a0) * OUTER_R, 0.0, sin(a0) * OUTER_R))
|
|
||||||
verts.append(Vector3(cos(a1) * OUTER_R, 0.0, sin(a1) * OUTER_R))
|
|
||||||
verts.append(Vector3(cos(a1) * INNER_R, 0.0, sin(a1) * INNER_R))
|
|
||||||
indices.append_array([vi, vi + 2, vi + 1, vi, vi + 3, vi + 2])
|
|
||||||
|
|
||||||
var arrays: Array = []
|
|
||||||
arrays.resize(Mesh.ARRAY_MAX)
|
|
||||||
arrays[Mesh.ARRAY_VERTEX] = verts
|
|
||||||
arrays[Mesh.ARRAY_INDEX] = indices
|
|
||||||
var mesh := ArrayMesh.new()
|
|
||||||
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
|
||||||
return mesh
|
|
||||||
|
|
||||||
|
|
||||||
func _host() -> Node:
|
|
||||||
if _player != null and _player.get_parent() != null:
|
|
||||||
return _player.get_parent()
|
|
||||||
return _player
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://sf5etma4kjt0
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
extends Node
|
|
||||||
## Body wobble and tail simulation. Legs stay in rest pose (no IK, no ragdoll).
|
|
||||||
## Created at runtime by player.gd; call setup() before the first _process tick.
|
|
||||||
|
|
||||||
var _player: CharacterBody3D
|
|
||||||
var _bull: Node3D
|
|
||||||
var _skeleton: Skeleton3D
|
|
||||||
|
|
||||||
# Body wobble
|
|
||||||
var _prev_vel: Vector3 = Vector3.ZERO
|
|
||||||
var _tilt_x: float = 0.0
|
|
||||||
var _tilt_z: float = 0.0
|
|
||||||
var _aerial_pitch: float = 0.0
|
|
||||||
|
|
||||||
var charge_pitch_target: float = 0.0
|
|
||||||
var _charge_pitch_curr: float = 0.0
|
|
||||||
|
|
||||||
# When true, another system (the roll's ball-tumble) owns the bull's full
|
|
||||||
# orientation — skip writing body wobble so we don't fight it.
|
|
||||||
var external_rotation: bool = false
|
|
||||||
|
|
||||||
# Tail — Verlet chain simulation
|
|
||||||
const TAIL_BONES: Array = [
|
|
||||||
&"tail_1", &"tail_2", &"tail_3", &"tail_4",
|
|
||||||
&"tail_5", &"tail_6",
|
|
||||||
&"tail_tip_1", &"tail_tip_2", &"tail_tip_2_end",
|
|
||||||
]
|
|
||||||
var _tail_idx: Array[int] = []
|
|
||||||
var _tail_world: Array[Vector3] = []
|
|
||||||
var _tail_prev: Array[Vector3] = []
|
|
||||||
var _tail_lengths: Array[float] = []
|
|
||||||
var _tail_rest_dir: Array[Vector3] = []
|
|
||||||
var _tail_parent_idx: int = -1
|
|
||||||
|
|
||||||
var tail_ragdoll: bool = false
|
|
||||||
var _prev_on_floor: bool = true
|
|
||||||
|
|
||||||
var _dbg_on: bool = false
|
|
||||||
var _dbg_nodes: Array[MeshInstance3D] = []
|
|
||||||
|
|
||||||
|
|
||||||
func setup(player: CharacterBody3D, bull: Node3D) -> void:
|
|
||||||
_player = player
|
|
||||||
_bull = bull
|
|
||||||
_skeleton = _find_skeleton(bull)
|
|
||||||
if not _skeleton:
|
|
||||||
push_error("BullLegs: Skeleton3D not found under bull")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Disable any physical bone simulator
|
|
||||||
var phys_sim := _find_physical_bones(_skeleton)
|
|
||||||
if phys_sim:
|
|
||||||
phys_sim.active = false
|
|
||||||
|
|
||||||
_setup_tail()
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_tail() -> void:
|
|
||||||
for bone_name: StringName in TAIL_BONES:
|
|
||||||
var idx: int = _skeleton.find_bone(bone_name)
|
|
||||||
if idx == -1:
|
|
||||||
push_warning("BullLegs: tail bone '%s' not found" % bone_name)
|
|
||||||
continue
|
|
||||||
_tail_idx.append(idx)
|
|
||||||
|
|
||||||
if _tail_idx.size() < 2:
|
|
||||||
push_error("BullLegs: not enough tail bones found")
|
|
||||||
return
|
|
||||||
|
|
||||||
for idx: int in _tail_idx:
|
|
||||||
var world: Vector3 = _skeleton.to_global(_skeleton.get_bone_global_rest(idx).origin)
|
|
||||||
_tail_world.append(world)
|
|
||||||
_tail_prev.append(world)
|
|
||||||
|
|
||||||
var max_sep: float = 0.0
|
|
||||||
for i in range(_tail_world.size() - 1):
|
|
||||||
max_sep = maxf(max_sep, _tail_world[i].distance_to(_tail_world[i + 1]))
|
|
||||||
if max_sep < 0.01:
|
|
||||||
push_warning("BullLegs: tail bones have zero separation in rest pose, distributing along spine")
|
|
||||||
var root: Vector3 = _tail_world[0]
|
|
||||||
var back: Vector3 = (_skeleton.global_transform.basis.z + Vector3.DOWN * 0.3).normalized()
|
|
||||||
for i in range(_tail_world.size()):
|
|
||||||
_tail_world[i] = root + back * (i * 0.09)
|
|
||||||
_tail_prev[i] = _tail_world[i]
|
|
||||||
|
|
||||||
const MIN_SEG: float = 0.06
|
|
||||||
for i in range(_tail_world.size() - 1):
|
|
||||||
var seg_len: float = _tail_world[i].distance_to(_tail_world[i + 1])
|
|
||||||
_tail_lengths.append(maxf(seg_len, MIN_SEG))
|
|
||||||
|
|
||||||
for i in range(_tail_world.size() - 1):
|
|
||||||
var p1_skel: Vector3 = _skeleton.to_local(_tail_world[i])
|
|
||||||
var p2_skel: Vector3 = _skeleton.to_local(_tail_world[i + 1])
|
|
||||||
var seg_skel: Vector3 = p2_skel - p1_skel
|
|
||||||
_tail_rest_dir.append(seg_skel.normalized() if seg_skel.length() > 0.001 else Vector3(0, 0, 1))
|
|
||||||
_tail_rest_dir.append(_tail_rest_dir[-1])
|
|
||||||
|
|
||||||
_tail_parent_idx = _skeleton.get_bone_parent(_tail_idx[0])
|
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
|
||||||
if not _skeleton:
|
|
||||||
return
|
|
||||||
_wobble_body(delta)
|
|
||||||
_update_tail(delta)
|
|
||||||
_apply_tail()
|
|
||||||
if DP.b("show_tail") != _dbg_on:
|
|
||||||
_dbg_toggle()
|
|
||||||
if _dbg_on:
|
|
||||||
_dbg_update()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Body Wobble ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func _wobble_body(delta: float) -> void:
|
|
||||||
var vel: Vector3 = _player.velocity
|
|
||||||
var accel: Vector3 = (vel - _prev_vel) / maxf(delta, 0.001)
|
|
||||||
_prev_vel = vel
|
|
||||||
|
|
||||||
var la: Vector3 = _bull.global_transform.basis.inverse() * accel
|
|
||||||
|
|
||||||
var tx := clampf(-la.z * DP.f("body_tilt_fwd"), -0.35, 0.35)
|
|
||||||
var tz := clampf(-la.x * DP.f("body_tilt_side"), -0.25, 0.25)
|
|
||||||
|
|
||||||
_tilt_x = lerpf(_tilt_x, tx, delta * 10.0)
|
|
||||||
_tilt_z = lerpf(_tilt_z, tz, delta * 10.0)
|
|
||||||
|
|
||||||
var flat_spd: float = Vector2(vel.x, vel.z).length()
|
|
||||||
var target_pitch: float = 0.0
|
|
||||||
if not _player.is_on_floor() and flat_spd > 0.5:
|
|
||||||
target_pitch = clampf(-atan2(vel.y, flat_spd) * DP.f("aerial_pitch_scale"), -1.4, 1.4)
|
|
||||||
_aerial_pitch = lerpf(_aerial_pitch, target_pitch, delta * DP.f("aerial_pitch_speed"))
|
|
||||||
|
|
||||||
_charge_pitch_curr = lerpf(_charge_pitch_curr, charge_pitch_target, delta * DP.f("charge_head_speed"))
|
|
||||||
if external_rotation:
|
|
||||||
return
|
|
||||||
_bull.rotation.x = _tilt_x + _aerial_pitch + _charge_pitch_curr
|
|
||||||
_bull.rotation.z = _tilt_z
|
|
||||||
|
|
||||||
|
|
||||||
# ── Tail ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func _update_tail(delta: float) -> void:
|
|
||||||
if _tail_world.is_empty():
|
|
||||||
return
|
|
||||||
|
|
||||||
var gravity := Vector3(0.0, -DP.f("tail_gravity"), 0.0)
|
|
||||||
var damping := 1.0 - clampf(DP.f("tail_damping"), 0.0, 0.99)
|
|
||||||
|
|
||||||
var anchor: Vector3
|
|
||||||
if _tail_parent_idx >= 0:
|
|
||||||
var parent_t := _skeleton.get_bone_global_pose(_tail_parent_idx)
|
|
||||||
anchor = _skeleton.to_global((parent_t * _skeleton.get_bone_rest(_tail_idx[0])).origin)
|
|
||||||
else:
|
|
||||||
anchor = _skeleton.to_global(_skeleton.get_bone_global_rest(_tail_idx[0]).origin)
|
|
||||||
_tail_world[0] = anchor
|
|
||||||
_tail_prev[0] = anchor
|
|
||||||
|
|
||||||
var on_floor := _player.is_on_floor()
|
|
||||||
if not on_floor and _prev_on_floor:
|
|
||||||
# Bull just left the ground — push all tail particles upward with it
|
|
||||||
for i: int in range(1, _tail_world.size()):
|
|
||||||
_tail_prev[i] = _tail_world[i] - _player.velocity * delta
|
|
||||||
_prev_on_floor = on_floor
|
|
||||||
|
|
||||||
var gravity_mult: float
|
|
||||||
if tail_ragdoll:
|
|
||||||
gravity_mult = 2.5
|
|
||||||
elif not on_floor:
|
|
||||||
gravity_mult = 0.15
|
|
||||||
else:
|
|
||||||
gravity_mult = 1.0
|
|
||||||
gravity *= gravity_mult
|
|
||||||
|
|
||||||
var flat_speed: float = Vector2(_player.velocity.x, _player.velocity.z).length()
|
|
||||||
var wag_strength: float = lerpf(DP.f("tail_wag_idle"), 0.0, clampf(flat_speed / 3.0, 0.0, 1.0))
|
|
||||||
var wag_freq: float = DP.f("tail_wag_freq")
|
|
||||||
var time: float = Time.get_ticks_msec() / 1000.0
|
|
||||||
var basis_x: Vector3 = _bull.global_transform.basis.x
|
|
||||||
|
|
||||||
for i in range(1, _tail_world.size()):
|
|
||||||
var vel: Vector3 = (_tail_world[i] - _tail_prev[i]) * damping
|
|
||||||
_tail_prev[i] = _tail_world[i]
|
|
||||||
var wag := Vector3.ZERO if tail_ragdoll \
|
|
||||||
else basis_x * sin(time * wag_freq + i * 0.5) * wag_strength
|
|
||||||
_tail_world[i] = _tail_world[i] + vel + gravity * (delta * delta) + wag * delta
|
|
||||||
|
|
||||||
for i in range(1, _tail_world.size()):
|
|
||||||
var dir: Vector3 = _tail_world[i] - _tail_world[i - 1]
|
|
||||||
var len: float = dir.length()
|
|
||||||
var target_len: float = _tail_lengths[i - 1]
|
|
||||||
if len > 0.0001:
|
|
||||||
_tail_world[i] = _tail_world[i - 1] + dir * (target_len / len)
|
|
||||||
else:
|
|
||||||
_tail_world[i] = _tail_world[i - 1] + Vector3(0.0, -target_len, 0.0)
|
|
||||||
|
|
||||||
var body_radius: float = DP.f("tail_body_radius")
|
|
||||||
for i in range(1, _tail_world.size()):
|
|
||||||
var p := _bull.to_local(_tail_world[i])
|
|
||||||
var closest := Vector3(0.0, 0.0, clampf(p.z, -0.3, 0.5))
|
|
||||||
var diff := p - closest
|
|
||||||
var dist := diff.length()
|
|
||||||
if dist < body_radius and dist > 0.001:
|
|
||||||
p = closest + diff.normalized() * body_radius
|
|
||||||
_tail_world[i] = _bull.to_global(p)
|
|
||||||
|
|
||||||
|
|
||||||
func _apply_tail() -> void:
|
|
||||||
if _tail_world.is_empty():
|
|
||||||
return
|
|
||||||
|
|
||||||
var n := _tail_world.size()
|
|
||||||
var local_pos: Array[Vector3] = []
|
|
||||||
local_pos.resize(n)
|
|
||||||
for i in n:
|
|
||||||
local_pos[i] = _skeleton.to_local(_tail_world[i])
|
|
||||||
|
|
||||||
for i in n:
|
|
||||||
var idx: int = _tail_idx[i]
|
|
||||||
var pos_skel: Vector3 = local_pos[i]
|
|
||||||
|
|
||||||
var point_dir_skel: Vector3
|
|
||||||
if i < n - 1:
|
|
||||||
point_dir_skel = (local_pos[i + 1] - pos_skel).normalized()
|
|
||||||
else:
|
|
||||||
point_dir_skel = (pos_skel - local_pos[i - 1]).normalized()
|
|
||||||
|
|
||||||
var rest_dir: Vector3 = _tail_rest_dir[i]
|
|
||||||
var rest_t: Transform3D = _skeleton.get_bone_global_rest(idx)
|
|
||||||
var new_basis: Basis
|
|
||||||
if rest_dir.length_squared() > 0.0001 and point_dir_skel.length_squared() > 0.0001:
|
|
||||||
new_basis = Basis(Quaternion(rest_dir, point_dir_skel)) * rest_t.basis
|
|
||||||
else:
|
|
||||||
new_basis = rest_t.basis
|
|
||||||
|
|
||||||
_skeleton.set_bone_global_pose_override(idx, Transform3D(new_basis, pos_skel), 1.0, false)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Debug Visualization ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func _dbg_toggle() -> void:
|
|
||||||
if _dbg_on:
|
|
||||||
for n: MeshInstance3D in _dbg_nodes:
|
|
||||||
if is_instance_valid(n):
|
|
||||||
n.queue_free()
|
|
||||||
_dbg_nodes.clear()
|
|
||||||
_dbg_on = false
|
|
||||||
else:
|
|
||||||
_dbg_build()
|
|
||||||
_dbg_on = true
|
|
||||||
|
|
||||||
|
|
||||||
func _dbg_build() -> void:
|
|
||||||
var root: Node = _player.get_parent()
|
|
||||||
var n: int = _tail_world.size()
|
|
||||||
for i: int in n:
|
|
||||||
var mat := StandardMaterial3D.new()
|
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
mat.render_priority = 1
|
|
||||||
mat.albedo_color = Color.RED if i == 0 \
|
|
||||||
else Color(1.0 - float(i) / n, 0.4 + 0.6 * float(i) / n, 0.0)
|
|
||||||
var sm := SphereMesh.new()
|
|
||||||
sm.radius = 0.10 if i == 0 else 0.06
|
|
||||||
sm.height = sm.radius * 2.0
|
|
||||||
sm.radial_segments = 6
|
|
||||||
sm.rings = 3
|
|
||||||
sm.material = mat
|
|
||||||
var mi := MeshInstance3D.new()
|
|
||||||
mi.mesh = sm
|
|
||||||
root.add_child(mi)
|
|
||||||
_dbg_nodes.append(mi)
|
|
||||||
|
|
||||||
|
|
||||||
func _dbg_update() -> void:
|
|
||||||
for i: int in _dbg_nodes.size():
|
|
||||||
_dbg_nodes[i].global_position = _tail_world[i]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Utility ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func _find_skeleton(node: Node) -> Skeleton3D:
|
|
||||||
if node is Skeleton3D:
|
|
||||||
return node as Skeleton3D
|
|
||||||
for child in node.get_children():
|
|
||||||
var r := _find_skeleton(child)
|
|
||||||
if r:
|
|
||||||
return r
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
func _find_physical_bones(node: Node) -> PhysicalBoneSimulator3D:
|
|
||||||
for child in node.get_children():
|
|
||||||
if child is PhysicalBoneSimulator3D:
|
|
||||||
return child as PhysicalBoneSimulator3D
|
|
||||||
return null
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bvtw8s41wrrx
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
class_name IsoCamera
|
|
||||||
extends Camera3D
|
|
||||||
|
|
||||||
@export var height: float = 25.0
|
|
||||||
@export var horizontal_distance: float = 25.0
|
|
||||||
@export var horizontal_angle_deg: float = 45.0
|
|
||||||
|
|
||||||
# Perspective zoom (FOV)
|
|
||||||
@export var fov_min: float = 20.0
|
|
||||||
@export var fov_max: float = 75.0
|
|
||||||
@export var default_fov: float = 30.0
|
|
||||||
|
|
||||||
@export var focus_slice_thickness: float = 6.0
|
|
||||||
@export var blur_transition: float = 15.0
|
|
||||||
|
|
||||||
var _shake_amount: float = 0.0
|
|
||||||
var _shake_decay: float = 0.0
|
|
||||||
var _user_fov: float = 0.0
|
|
||||||
|
|
||||||
# Per-level framing multiplier: 1.0 = the designer's base shot, >1 dollies the camera
|
|
||||||
# back for a wider view (the lobby uses this). Set by game_shell when a level loads;
|
|
||||||
# kept separate from the scroll-wheel FOV zoom so the two don't fight.
|
|
||||||
var _framing_scale: float = 1.0
|
|
||||||
|
|
||||||
# Trailing-camera state: _focus is the smoothed point the camera frames (it lags
|
|
||||||
# the bull), _drag_offset is the velocity-driven trail that slides it behind.
|
|
||||||
var _focus: Vector3 = Vector3.ZERO
|
|
||||||
var _drag_offset: Vector3 = Vector3.ZERO
|
|
||||||
var _focus_init: bool = false
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
set_as_top_level(true)
|
|
||||||
|
|
||||||
projection = Camera3D.PROJECTION_PERSPECTIVE
|
|
||||||
fov = default_fov
|
|
||||||
_user_fov = default_fov
|
|
||||||
|
|
||||||
near = 0.1
|
|
||||||
far = 150.0
|
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
|
||||||
if event.is_action_pressed("zoom_in"):
|
|
||||||
_user_fov = clamp(_user_fov / 1.1, fov_min, fov_max)
|
|
||||||
|
|
||||||
if event.is_action_pressed("zoom_out"):
|
|
||||||
_user_fov = clamp(_user_fov * 1.1, fov_min, fov_max)
|
|
||||||
|
|
||||||
|
|
||||||
# Pinch-to-zoom hook for the touch UI. `ratio` is the two-finger spread this frame over
|
|
||||||
# last frame: >1 (fingers parting) narrows the FOV to zoom in, <1 widens it to pull back,
|
|
||||||
# mirroring the scroll-wheel zoom_in/zoom_out feel.
|
|
||||||
func adjust_zoom(ratio: float) -> void:
|
|
||||||
if ratio <= 0.0:
|
|
||||||
return
|
|
||||||
_user_fov = clampf(_user_fov / ratio, fov_min, fov_max)
|
|
||||||
|
|
||||||
|
|
||||||
## Dolly the camera in/out for the level being loaded. 1.0 is the base framing; >1 pulls
|
|
||||||
## back for a wider shot (the lobby hub uses this). Independent of the scroll-wheel zoom.
|
|
||||||
func set_framing_scale(scale: float) -> void:
|
|
||||||
_framing_scale = maxf(scale, 0.1)
|
|
||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
|
||||||
fov = lerpf(fov, _user_fov, delta * 8.0)
|
|
||||||
|
|
||||||
var angle_rad := deg_to_rad(horizontal_angle_deg)
|
|
||||||
|
|
||||||
var offset := Vector3(
|
|
||||||
sin(angle_rad) * horizontal_distance * _framing_scale,
|
|
||||||
height * _framing_scale,
|
|
||||||
cos(angle_rad) * horizontal_distance * _framing_scale
|
|
||||||
)
|
|
||||||
|
|
||||||
var target := get_parent() as Node3D
|
|
||||||
var focus := _drag_focus(target, delta)
|
|
||||||
|
|
||||||
global_position = focus + offset
|
|
||||||
look_at(focus, Vector3.UP)
|
|
||||||
|
|
||||||
_update_dof(focus)
|
|
||||||
|
|
||||||
if _shake_amount > 0.005:
|
|
||||||
global_position += Vector3(
|
|
||||||
randf_range(-_shake_amount, _shake_amount),
|
|
||||||
randf_range(-_shake_amount * 0.25, _shake_amount * 0.25),
|
|
||||||
randf_range(-_shake_amount, _shake_amount)
|
|
||||||
)
|
|
||||||
|
|
||||||
_shake_amount = move_toward(_shake_amount, 0.0, _shake_decay * delta)
|
|
||||||
|
|
||||||
|
|
||||||
# Smoothed follow point that drags behind the bull. The target trail sits opposite
|
|
||||||
# the bull's flat velocity (cam_drag metres per m/s); the offset and the focus both
|
|
||||||
# ease toward their targets so the camera lags and settles rather than snapping.
|
|
||||||
func _drag_focus(target: Node3D, delta: float) -> Vector3:
|
|
||||||
var pos := target.global_position
|
|
||||||
if not _focus_init:
|
|
||||||
_focus = pos
|
|
||||||
_focus_init = true
|
|
||||||
|
|
||||||
var vel := Vector3.ZERO
|
|
||||||
if target is CharacterBody3D:
|
|
||||||
vel = (target as CharacterBody3D).velocity
|
|
||||||
vel.y = 0.0
|
|
||||||
|
|
||||||
var drag_target := -vel * DP.f("cam_drag")
|
|
||||||
_drag_offset = _drag_offset.lerp(drag_target, clampf(DP.f("cam_drag_speed") * delta, 0.0, 1.0))
|
|
||||||
_focus = _focus.lerp(pos + _drag_offset, clampf(DP.f("cam_follow_lerp") * delta, 0.0, 1.0))
|
|
||||||
return _focus
|
|
||||||
|
|
||||||
|
|
||||||
func trigger_hit(strength: float) -> void:
|
|
||||||
if not Settings.screen_shake:
|
|
||||||
return
|
|
||||||
_shake_amount = strength * 0.5
|
|
||||||
_shake_decay = _shake_amount / 0.4
|
|
||||||
|
|
||||||
|
|
||||||
func _update_dof(target_pos: Vector3) -> void:
|
|
||||||
var cam_attr: CameraAttributesPractical = null
|
|
||||||
|
|
||||||
if attributes is CameraAttributesPractical:
|
|
||||||
cam_attr = attributes
|
|
||||||
|
|
||||||
if cam_attr == null:
|
|
||||||
return
|
|
||||||
|
|
||||||
var distance := global_position.distance_to(target_pos)
|
|
||||||
|
|
||||||
cam_attr.dof_blur_near_distance = max(distance - focus_slice_thickness * 0.5, near + 0.1)
|
|
||||||
cam_attr.dof_blur_far_distance = distance + focus_slice_thickness * 0.5
|
|
||||||
|
|
||||||
cam_attr.dof_blur_near_transition = blur_transition
|
|
||||||
cam_attr.dof_blur_far_transition = blur_transition
|
|
||||||
|
|
||||||
cam_attr.dof_blur_near_enabled = false
|
|
||||||
cam_attr.dof_blur_far_enabled = false
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cpyq2r5cq653g
|
|
||||||
@@ -1,944 +0,0 @@
|
|||||||
extends CharacterBody3D
|
|
||||||
|
|
||||||
# Hoof positions in bull local space — dust emitter offsets
|
|
||||||
const HOOF_OFFSETS: Array = [
|
|
||||||
Vector3(-0.30, -0.25, -0.50),
|
|
||||||
Vector3( 0.30, -0.25, -0.50),
|
|
||||||
Vector3(-0.30, -0.25, 0.60),
|
|
||||||
Vector3( 0.30, -0.25, 0.60),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Bull animation clips (Armature|* action names in art/characters/bull.fbx). IDLE is the
|
|
||||||
# looping base pose; the others are one-shot ability clips that return to it.
|
|
||||||
const _ANIM_IDLE: StringName = &"Armature|IDLE"
|
|
||||||
const _ANIM_KICK: StringName = &"Armature|KICK"
|
|
||||||
const _ANIM_SLAM: StringName = &"Armature|SLAM"
|
|
||||||
const _ANIM_DASH: StringName = &"Armature|DASH"
|
|
||||||
const _ANIM_WALK: StringName = &"Armature|WALK"
|
|
||||||
const _ANIM_ROLL: StringName = &"Armature|ROLL"
|
|
||||||
const _ANIM_DEATH: StringName = &"Armature|DEATH"
|
|
||||||
|
|
||||||
@onready var camera_pivot: Node3D = $Camera3D
|
|
||||||
@onready var cube_guy: Node3D = $bull
|
|
||||||
|
|
||||||
var _hoof_emitters: Array[CPUParticles3D] = []
|
|
||||||
var _legs: Node
|
|
||||||
|
|
||||||
var _was_on_floor: bool = false
|
|
||||||
var _pre_slide_vel_y: float = 0.0
|
|
||||||
|
|
||||||
# Built once and reused: a fresh StandardMaterial3D/SphereMesh per spawn makes a mobile WebGL
|
|
||||||
# driver recompile the shader + re-upload the mesh — a hitch "when attacking" absent on desktop.
|
|
||||||
var _unshaded_mat: StandardMaterial3D = null
|
|
||||||
var _particle_sphere_cache: Dictionary = {} # "radius:seg:ring" -> SphereMesh (shared)
|
|
||||||
var _impact_fx: Node = null # bull_impact_fx.gd — the coded (non-particle) slam rings + kick flash
|
|
||||||
|
|
||||||
# Ability system — kick=0, dash=1, slam=2, roll=3
|
|
||||||
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
|
|
||||||
var _ability_active: bool = false
|
|
||||||
var _ability_timer: float = 0.0
|
|
||||||
var _active_ability: int = -1
|
|
||||||
var _dash_dir: Vector3 = Vector3.ZERO
|
|
||||||
var _dash_speed_cur: float = 0.0
|
|
||||||
var _roll_dir: Vector3 = Vector3.ZERO
|
|
||||||
var _roll_ramp: float = 0.0
|
|
||||||
var _roll_spin: float = 0.0
|
|
||||||
var _roll_target: Node3D = null
|
|
||||||
var _roll_chains_left: int = 0
|
|
||||||
var _roll_hit: Array[Node] = []
|
|
||||||
var _bull_anim: AnimationPlayer = null
|
|
||||||
# Locomotion animation state: true while the looping DASH clip is driving WASD
|
|
||||||
# movement, false while idling. Flipping it cross-fades between the two clips.
|
|
||||||
var _locomotion_moving: bool = false
|
|
||||||
|
|
||||||
var _slam_pending: bool = false
|
|
||||||
var _charge_trail_emitter: CPUParticles3D = null
|
|
||||||
var _roll_spark_emitter: CPUParticles3D = null
|
|
||||||
var _cube_scale: Vector3 = Vector3.ONE
|
|
||||||
|
|
||||||
# One clean hit ends the run — see take_sword_hit(). The HUD listens for `died`
|
|
||||||
# to raise the lose screen (the `cause` tags how it happened, for the death notice);
|
|
||||||
# _dead latches so a flurry of hits fires it only once.
|
|
||||||
signal died(cause: String)
|
|
||||||
var _dead: bool = false
|
|
||||||
|
|
||||||
# HP system — the bull soaks several clean hits instead of dying to the first.
|
|
||||||
# Each landed blade drops one pip and starts an i-frame window (so one pass can't
|
|
||||||
# empty the bar); at 0 the run ends via `died`. The HUD listens on `health_changed`.
|
|
||||||
signal health_changed(current: int, max_hp: int)
|
|
||||||
# Fired on every landed hit (after i-frames pass) so the HUD can flash the damage
|
|
||||||
# vignette; `cause` mirrors take_sword_hit ("gored" / "thrown").
|
|
||||||
signal hit_taken(cause: String)
|
|
||||||
var _max_hp: int = 10
|
|
||||||
var _hp: int = 10
|
|
||||||
# Bonus pips granted by lobby rewards (e.g. smashing every barrel). Tacked on top of the
|
|
||||||
# base bar; the HUD paints these top pips yellow instead of red.
|
|
||||||
var _bonus_hp: int = 0
|
|
||||||
var _hit_iframes: float = 0.0
|
|
||||||
|
|
||||||
var _huff_player: AudioStreamPlayer = null
|
|
||||||
var _crowd_player: AudioStreamPlayer = null
|
|
||||||
var _huff_timer: float = 0.0
|
|
||||||
|
|
||||||
enum DustState { NONE, WALK, CHARGE }
|
|
||||||
var _dust_state: DustState = DustState.NONE
|
|
||||||
# Hoof-dust scale/velocity per state (the look — mesh, colour, curve — lives in bull_hoof_dust.tscn).
|
|
||||||
# A charge sprint kicks up bigger, faster dust than a walk.
|
|
||||||
const _WALK_DUST_SCALE: Vector2 = Vector2(0.20, 0.40)
|
|
||||||
const _WALK_DUST_VEL: Vector2 = Vector2(0.8, 1.8)
|
|
||||||
const _CHARGE_DUST_SCALE: Vector2 = Vector2(0.35, 0.70)
|
|
||||||
const _CHARGE_DUST_VEL: Vector2 = Vector2(2.8, 5.5)
|
|
||||||
|
|
||||||
|
|
||||||
const RigidSkin = preload("res://systems/render/rigid_skin.gd")
|
|
||||||
|
|
||||||
var _hit_flash_tween: Tween
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
add_to_group(&"player")
|
|
||||||
# Native GPU skinning by default (the old Mali/ANGLE invisible-skinning bug is fixed in the
|
|
||||||
# 4.7 web templates). rigid_skin is now an opt-in fallback — see Controls.rigid_skin_enabled().
|
|
||||||
if Controls.rigid_skin_enabled():
|
|
||||||
RigidSkin.convert_tree(self)
|
|
||||||
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
|
|
||||||
_hp = _max_hp
|
|
||||||
# Carry any run-long bonus pips (barrel reward) into this level so they survive the
|
|
||||||
# scene reload between fights; the HUD paints these top pips yellow.
|
|
||||||
_bonus_hp = Run.bonus_hp
|
|
||||||
_max_hp += _bonus_hp
|
|
||||||
_hp += _bonus_hp
|
|
||||||
_cube_scale = cube_guy.scale
|
|
||||||
_setup_impact_fx()
|
|
||||||
_setup_hoof_dust()
|
|
||||||
_setup_legs()
|
|
||||||
_setup_charge_trail()
|
|
||||||
_setup_roll_sparks()
|
|
||||||
_setup_audio()
|
|
||||||
_bull_anim = _find_anim_player(cube_guy)
|
|
||||||
_setup_bull_idle()
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_legs() -> void:
|
|
||||||
_legs = Node.new()
|
|
||||||
_legs.set_script(load("res://actors/player/bull_legs.gd"))
|
|
||||||
add_child(_legs)
|
|
||||||
_legs.call(&"setup", self, cube_guy)
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_impact_fx() -> void:
|
|
||||||
_impact_fx = Node.new()
|
|
||||||
_impact_fx.set_script(load("res://actors/player/bull_impact_fx.gd"))
|
|
||||||
add_child(_impact_fx)
|
|
||||||
_impact_fx.call(&"setup", self)
|
|
||||||
|
|
||||||
|
|
||||||
# The bull's ability/movement particle effects are authored as scenes in systems/fx/ (so Siim can
|
|
||||||
# tune them in the inspector). The continuous ones follow the bull — instanced once here as
|
|
||||||
# children, then toggled via `emitting`. The one-shots are fired on demand through Fx.spawn().
|
|
||||||
func _setup_hoof_dust() -> void:
|
|
||||||
for offset: Vector3 in HOOF_OFFSETS:
|
|
||||||
var p := Fx.attach(&"bull_hoof_dust", cube_guy) as CPUParticles3D
|
|
||||||
if p == null:
|
|
||||||
continue
|
|
||||||
p.position = offset
|
|
||||||
_hoof_emitters.append(p)
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_charge_trail() -> void:
|
|
||||||
_charge_trail_emitter = Fx.attach(&"bull_charge_trail", cube_guy) as CPUParticles3D
|
|
||||||
if _charge_trail_emitter != null:
|
|
||||||
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_roll_sparks() -> void:
|
|
||||||
_roll_spark_emitter = Fx.attach(&"bull_roll_sparks", cube_guy) as CPUParticles3D
|
|
||||||
if _roll_spark_emitter != null:
|
|
||||||
_roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
func _setup_audio() -> void:
|
|
||||||
if ResourceLoader.exists("res://sounds/bull_huff.ogg"):
|
|
||||||
_huff_player = AudioStreamPlayer.new()
|
|
||||||
_huff_player.stream = load("res://sounds/bull_huff.ogg")
|
|
||||||
_huff_player.volume_db = 0.0
|
|
||||||
_huff_player.bus = &"Effects"
|
|
||||||
add_child(_huff_player)
|
|
||||||
if ResourceLoader.exists("res://sounds/crowd_ambient.ogg"):
|
|
||||||
_crowd_player = AudioStreamPlayer.new()
|
|
||||||
var s := load("res://sounds/crowd_ambient.ogg") as AudioStreamOggVorbis
|
|
||||||
if s:
|
|
||||||
s.loop = true
|
|
||||||
_crowd_player.stream = s
|
|
||||||
_crowd_player.volume_db = -14.0
|
|
||||||
_crowd_player.autoplay = true
|
|
||||||
_crowd_player.bus = &"Ambient"
|
|
||||||
add_child(_crowd_player)
|
|
||||||
|
|
||||||
|
|
||||||
# Toggle the hoof emitters and swap their scale/velocity for the walk vs charge feel. The blue
|
|
||||||
# charge trail is the roll/dash ability's, driven from the roll loop, not by dust state.
|
|
||||||
func _set_dust_state(new_state: DustState) -> void:
|
|
||||||
if new_state == _dust_state:
|
|
||||||
return
|
|
||||||
_dust_state = new_state
|
|
||||||
var scale := _CHARGE_DUST_SCALE if new_state == DustState.CHARGE else _WALK_DUST_SCALE
|
|
||||||
var vel := _CHARGE_DUST_VEL if new_state == DustState.CHARGE else _WALK_DUST_VEL
|
|
||||||
for p: CPUParticles3D in _hoof_emitters:
|
|
||||||
p.scale_amount_min = scale.x
|
|
||||||
p.scale_amount_max = scale.y
|
|
||||||
p.initial_velocity_min = vel.x
|
|
||||||
p.initial_velocity_max = vel.y
|
|
||||||
p.emitting = new_state != DustState.NONE
|
|
||||||
|
|
||||||
|
|
||||||
func _find_anim_player(node: Node) -> AnimationPlayer:
|
|
||||||
if node is AnimationPlayer:
|
|
||||||
return node as AnimationPlayer
|
|
||||||
for child: Node in node.get_children():
|
|
||||||
var r := _find_anim_player(child)
|
|
||||||
if r:
|
|
||||||
return r
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
# IDLE ships one-shot; make it loop and start it so the bull breathes at rest
|
|
||||||
# instead of freezing on a bind pose. It's the base every ability blends back to.
|
|
||||||
func _setup_bull_idle() -> void:
|
|
||||||
if _bull_anim == null:
|
|
||||||
return
|
|
||||||
# IDLE, DASH and ROLL loop as continuous states (DASH doubles as the WASD
|
|
||||||
# locomotion cycle); the remaining ability clips are one-shot.
|
|
||||||
for clip: StringName in [_ANIM_IDLE, _ANIM_DASH, _ANIM_ROLL]:
|
|
||||||
if _bull_anim.has_animation(clip):
|
|
||||||
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
|
|
||||||
_play_idle()
|
|
||||||
|
|
||||||
|
|
||||||
# Return to the looping idle (and undo any ability speed-scale). Cross-fades so a
|
|
||||||
# finishing ability clip eases back to idle instead of snapping.
|
|
||||||
func _play_idle() -> void:
|
|
||||||
_locomotion_moving = false
|
|
||||||
if _bull_anim == null:
|
|
||||||
return
|
|
||||||
_bull_anim.speed_scale = 1.0
|
|
||||||
if _bull_anim.has_animation(_ANIM_IDLE):
|
|
||||||
_bull_anim.play(_ANIM_IDLE, 0.05)
|
|
||||||
|
|
||||||
|
|
||||||
# Drive the WASD locomotion clip: the looping DASH doubles as the run cycle while a
|
|
||||||
# direction is held, easing back to idle when the bull coasts to a stop. Cross-fades
|
|
||||||
# on each transition so idle↔run (and steering into it after an ability) blend rather
|
|
||||||
# than snap. Abilities own the animation while active, so this yields to them.
|
|
||||||
func _update_locomotion_anim(moving: bool) -> void:
|
|
||||||
if _ability_active or _bull_anim == null or moving == _locomotion_moving:
|
|
||||||
return
|
|
||||||
_locomotion_moving = moving
|
|
||||||
if moving:
|
|
||||||
if _bull_anim.has_animation(_ANIM_WALK):
|
|
||||||
_bull_anim.speed_scale = 2.0
|
|
||||||
_bull_anim.play(_ANIM_WALK, 1)
|
|
||||||
var anim = _bull_anim.get_animation(_ANIM_WALK)
|
|
||||||
anim.loop_mode = Animation.LOOP_LINEAR
|
|
||||||
else:
|
|
||||||
_play_idle()
|
|
||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
|
||||||
if _dead:
|
|
||||||
return
|
|
||||||
# Abilities can be chained: a new one interrupts whatever is currently
|
|
||||||
# active, gated only by each ability's own cooldown.
|
|
||||||
if event.is_action_pressed(&"ability_kick"):
|
|
||||||
try_activate_ability(0)
|
|
||||||
elif event.is_action_pressed(&"ability_dash"):
|
|
||||||
try_activate_ability(1)
|
|
||||||
elif event.is_action_pressed(&"ability_slam"):
|
|
||||||
try_activate_ability(2)
|
|
||||||
elif event.is_action_pressed(&"ability_roll"):
|
|
||||||
try_activate_ability(3)
|
|
||||||
|
|
||||||
|
|
||||||
# Public ability entry point shared by keyboard/gamepad input and the on-screen
|
|
||||||
# touch buttons. Slot order matches ability_cd — kick=0, dash=1, slam=2, roll=3.
|
|
||||||
# No-ops if the bull is dead or the slot is still cooling down.
|
|
||||||
func try_activate_ability(slot: int) -> void:
|
|
||||||
if _dead or slot < 0 or slot >= ability_cd.size() or ability_cd[slot] > 0.0:
|
|
||||||
return
|
|
||||||
match slot:
|
|
||||||
0: _activate_kick()
|
|
||||||
1: _activate_dash()
|
|
||||||
2: _activate_slam()
|
|
||||||
3: _activate_roll()
|
|
||||||
|
|
||||||
|
|
||||||
func ability_cooldown_fraction(slot: int) -> float:
|
|
||||||
var maxcd := 0.0
|
|
||||||
match slot:
|
|
||||||
0: maxcd = DP.f("kick_cooldown")
|
|
||||||
1: maxcd = DP.f("dash_cooldown")
|
|
||||||
2: maxcd = DP.f("slam_cooldown")
|
|
||||||
3: maxcd = DP.f("roll_cooldown")
|
|
||||||
return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
func ability_cooldown_remaining(slot: int) -> float:
|
|
||||||
return ability_cd[slot]
|
|
||||||
|
|
||||||
|
|
||||||
func _activate_kick() -> void:
|
|
||||||
ability_cd[0] = DP.f("kick_cooldown")
|
|
||||||
_ability_active = true
|
|
||||||
_active_ability = 0
|
|
||||||
_ability_timer = 0.3
|
|
||||||
if _bull_anim:
|
|
||||||
_bull_anim.speed_scale = 2.0
|
|
||||||
if _bull_anim and _bull_anim.has_animation(_ANIM_KICK):
|
|
||||||
_bull_anim.play(_ANIM_KICK)
|
|
||||||
_ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
|
|
||||||
var raw := cube_guy.global_transform.basis.z
|
|
||||||
var back := -Vector3(raw.x, 0.0, raw.z).normalized()
|
|
||||||
_spawn_kick_fx(back)
|
|
||||||
get_tree().create_timer(0.2).timeout.connect(
|
|
||||||
func() -> void: _hit_matadors_cone(DP.f("kick_range"), deg_to_rad(85.0), DP.f("kick_strength"), back)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _activate_slam() -> void:
|
|
||||||
ability_cd[2] = DP.f("slam_cooldown")
|
|
||||||
_ability_active = true
|
|
||||||
_active_ability = 2
|
|
||||||
_legs.set(&"tail_ragdoll", true)
|
|
||||||
_slam_pending = true
|
|
||||||
SpearProjectile.shed_all.call_deferred(self) # the leap shakes off any stuck spears
|
|
||||||
var jump_vel := DP.f("slam_jump_velocity")
|
|
||||||
velocity.y = jump_vel
|
|
||||||
# Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)).
|
|
||||||
var fall_mult := maxf(DP.f("slam_fall_mult"), 0.01)
|
|
||||||
var air_time := (jump_vel / 9.8) * (1.0 + 1.0 / sqrt(fall_mult))
|
|
||||||
_ability_timer = air_time + 0.3
|
|
||||||
if _bull_anim and _bull_anim.has_animation(_ANIM_SLAM):
|
|
||||||
var anim_len := _bull_anim.get_animation(_ANIM_SLAM).length
|
|
||||||
_bull_anim.speed_scale = anim_len / maxf(air_time, 0.01)
|
|
||||||
_bull_anim.play(_ANIM_SLAM)
|
|
||||||
|
|
||||||
|
|
||||||
func _activate_dash() -> void:
|
|
||||||
ability_cd[1] = DP.f("dash_cooldown")
|
|
||||||
_ability_active = true
|
|
||||||
_active_ability = 1
|
|
||||||
_ability_timer = 0.45
|
|
||||||
if _bull_anim:
|
|
||||||
_bull_anim.speed_scale = 1.0
|
|
||||||
if _bull_anim and _bull_anim.has_animation(_ANIM_DASH):
|
|
||||||
_bull_anim.play(_ANIM_DASH)
|
|
||||||
_ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
|
|
||||||
var raw := cube_guy.global_transform.basis.z
|
|
||||||
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
|
|
||||||
_dash_speed_cur = DP.f("dash_speed")
|
|
||||||
velocity.x = _dash_dir.x * _dash_speed_cur
|
|
||||||
velocity.z = _dash_dir.z * _dash_speed_cur
|
|
||||||
_spawn_dash_burst()
|
|
||||||
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
|
|
||||||
if _huff_player:
|
|
||||||
_huff_player.pitch_scale = randf_range(0.9, 1.1)
|
|
||||||
_huff_player.play()
|
|
||||||
|
|
||||||
|
|
||||||
func _activate_roll() -> void:
|
|
||||||
ability_cd[3] = DP.f("roll_cooldown")
|
|
||||||
_ability_active = true
|
|
||||||
_active_ability = 3
|
|
||||||
_ability_timer = DP.f("roll_duration")
|
|
||||||
_roll_ramp = 0.0
|
|
||||||
_roll_spin = 0.0
|
|
||||||
_roll_chains_left = int(DP.f("roll_chain_count"))
|
|
||||||
_roll_hit.clear()
|
|
||||||
# Lock onto the closest matador and curl into the ball aimed at it; if the arena
|
|
||||||
# is empty, launch along the current heading (or facing when standing still).
|
|
||||||
_roll_target = _roll_pick_target()
|
|
||||||
var flat := Vector3(velocity.x, 0.0, velocity.z)
|
|
||||||
if _roll_target != null:
|
|
||||||
var to_t := _roll_target.global_position - global_position
|
|
||||||
_roll_dir = Vector3(to_t.x, 0.0, to_t.z).normalized()
|
|
||||||
elif flat.length() > 1.0:
|
|
||||||
_roll_dir = flat.normalized()
|
|
||||||
else:
|
|
||||||
var f := cube_guy.global_transform.basis.z
|
|
||||||
_roll_dir = Vector3(f.x, 0.0, f.z).normalized()
|
|
||||||
var launch := DP.f("roll_base_speed")
|
|
||||||
velocity.x = _roll_dir.x * launch
|
|
||||||
velocity.z = _roll_dir.z * launch
|
|
||||||
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
|
|
||||||
_bull_anim.speed_scale = 1.0
|
|
||||||
_bull_anim.play(_ANIM_ROLL)
|
|
||||||
_legs.set(&"tail_ragdoll", true) # tail streams out behind
|
|
||||||
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) # horns down, plowing
|
|
||||||
_legs.set(&"external_rotation", true) # roll owns the model's full orientation (tumble)
|
|
||||||
if _roll_spark_emitter:
|
|
||||||
_roll_spark_emitter.emitting = true
|
|
||||||
_spawn_dash_burst()
|
|
||||||
camera_pivot.call(&"trigger_hit", 0.35)
|
|
||||||
if _huff_player:
|
|
||||||
_huff_player.pitch_scale = randf_range(0.8, 1.0)
|
|
||||||
_huff_player.play()
|
|
||||||
|
|
||||||
|
|
||||||
# One rolling frame: home on the current target matador, accelerate the longer the
|
|
||||||
# roll lasts, move (walls just slide), then pop the ball on contact and chain to the
|
|
||||||
# next closest. Fully replaces the normal locomotion path while active. `steer` is
|
|
||||||
# ignored — the roll auto-targets — but kept so the caller stays uniform.
|
|
||||||
func _tick_roll(delta: float, _steer: Vector3) -> void:
|
|
||||||
# Reacquire if the target ragdolled or was freed before we reached it.
|
|
||||||
if not _roll_target_valid():
|
|
||||||
_roll_target = _roll_pick_target()
|
|
||||||
if _roll_target != null:
|
|
||||||
var to_t := _roll_target.global_position - global_position
|
|
||||||
var flat := Vector3(to_t.x, 0.0, to_t.z)
|
|
||||||
if flat.length() > 0.1:
|
|
||||||
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
|
|
||||||
_roll_dir = _roll_dir.slerp(flat.normalized(), t).normalized()
|
|
||||||
|
|
||||||
# Speed builds over time: base → max across roll_rampup_time, so a long roll
|
|
||||||
# winds up into a runaway boulder while a quick tap barely gets going.
|
|
||||||
_roll_ramp += delta
|
|
||||||
var ramp := clampf(_roll_ramp / maxf(DP.f("roll_rampup_time"), 0.01), 0.0, 1.0)
|
|
||||||
var top := DP.f("roll_max_speed")
|
|
||||||
var speed := lerpf(DP.f("roll_base_speed"), top, ramp)
|
|
||||||
velocity.x = _roll_dir.x * speed
|
|
||||||
velocity.z = _roll_dir.z * speed
|
|
||||||
|
|
||||||
# Tumble the whole model like a Powerball: face the travel direction (yaw) then
|
|
||||||
# spin forward about the horizontal axis perpendicular to it, angular speed = v/r
|
|
||||||
# so it "rolls" without slipping. Legs yield rotation control while active.
|
|
||||||
var radius := maxf(DP.f("roll_spin_radius"), 0.1)
|
|
||||||
_roll_spin = wrapf(_roll_spin + (speed / radius) * delta, 0.0, TAU)
|
|
||||||
var facing := Basis(Vector3.UP, atan2(_roll_dir.x, _roll_dir.z))
|
|
||||||
var tumble_axis := Vector3.UP.cross(_roll_dir).normalized()
|
|
||||||
cube_guy.basis = (Basis(tumble_axis, _roll_spin) * facing).scaled(_cube_scale)
|
|
||||||
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
|
|
||||||
_bull_anim.speed_scale = clampf(speed / maxf(top, 1.0), 0.6, 2.4)
|
|
||||||
|
|
||||||
_set_dust_state(DustState.CHARGE)
|
|
||||||
if _charge_trail_emitter:
|
|
||||||
_charge_trail_emitter.emitting = true
|
|
||||||
_charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
|
|
||||||
|
|
||||||
move_and_slide()
|
|
||||||
_was_on_floor = is_on_floor()
|
|
||||||
_smash_barrels_in_radius(DP.f("roll_hit_radius"))
|
|
||||||
_roll_try_pop(speed)
|
|
||||||
|
|
||||||
|
|
||||||
# Pop the ball on ramming a matador: launch every matador in the hit radius up and
|
|
||||||
# outward, then either CHAIN to the next closest (if chains remain and a target is
|
|
||||||
# left) or end the roll. Returns true if it popped so the caller can stop touching
|
|
||||||
# roll state this frame.
|
|
||||||
func _roll_try_pop(speed: float) -> bool:
|
|
||||||
var radius := DP.f("roll_hit_radius")
|
|
||||||
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
||||||
if _roll_hit.has(mat) or not _mat_active(mat):
|
|
||||||
continue
|
|
||||||
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
|
||||||
to_mat.y = 0.0
|
|
||||||
if to_mat.length() > radius:
|
|
||||||
continue
|
|
||||||
# Record everyone this pop reaches so the chain never re-picks a downed body.
|
|
||||||
for m: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
||||||
var d: Vector3 = (m as Node3D).global_position - global_position
|
|
||||||
d.y = 0.0
|
|
||||||
if d.length() <= radius and not _roll_hit.has(m):
|
|
||||||
_roll_hit.append(m)
|
|
||||||
_hit_matadors_radius(radius, DP.f("roll_hit_strength"), DP.f("roll_knockup"))
|
|
||||||
camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0))
|
|
||||||
if _huff_player:
|
|
||||||
_huff_player.pitch_scale = randf_range(1.05, 1.35)
|
|
||||||
_huff_player.play()
|
|
||||||
if _roll_chains_left > 0:
|
|
||||||
var next := _roll_pick_target()
|
|
||||||
if next != null:
|
|
||||||
_roll_chains_left -= 1
|
|
||||||
_roll_target = next
|
|
||||||
return true # keep rolling — the ball carries on to the next target
|
|
||||||
_end_roll()
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
# Closest thing the roll should home on — an active matador (arena) or an intact barrel
|
|
||||||
# (lobby) we haven't already smashed this roll. Null if nothing is left to hit.
|
|
||||||
func _roll_pick_target() -> Node3D:
|
|
||||||
var best: Node3D = null
|
|
||||||
var best_d := INF
|
|
||||||
for cand: Node3D in _roll_targets():
|
|
||||||
var d: float = cand.global_position.distance_squared_to(global_position)
|
|
||||||
if d < best_d:
|
|
||||||
best_d = d
|
|
||||||
best = cand
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
# Live roll targets: active matadors plus standing barrels, minus anything already popped.
|
|
||||||
func _roll_targets() -> Array[Node3D]:
|
|
||||||
var out: Array[Node3D] = []
|
|
||||||
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
||||||
if not _roll_hit.has(mat) and _mat_active(mat):
|
|
||||||
out.append(mat as Node3D)
|
|
||||||
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
|
|
||||||
if _barrel_alive(barrel):
|
|
||||||
out.append(barrel as Node3D)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
func _roll_target_valid() -> bool:
|
|
||||||
if not is_instance_valid(_roll_target):
|
|
||||||
return false
|
|
||||||
if _roll_target.is_in_group(&"barrel"):
|
|
||||||
return _barrel_alive(_roll_target)
|
|
||||||
return _mat_active(_roll_target)
|
|
||||||
|
|
||||||
|
|
||||||
# A matador still in play — valid, not ragdolled/consumed. Ragdolled bodies report
|
|
||||||
# is_active() == false, so a popped target is skipped on the next chain search.
|
|
||||||
func _mat_active(mat: Node) -> bool:
|
|
||||||
return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active")
|
|
||||||
|
|
||||||
|
|
||||||
# A lobby barrel still standing — valid and not already queued for removal by an earlier hit.
|
|
||||||
func _barrel_alive(barrel: Node) -> bool:
|
|
||||||
return is_instance_valid(barrel) and not barrel.is_queued_for_deletion()
|
|
||||||
|
|
||||||
|
|
||||||
# Burst every barrel whose centre sits within range_m on the ground plane. Shared by the
|
|
||||||
# boulder roll (which ploughs straight through them) and the slam shockwave. Each barrel
|
|
||||||
# self-removes and leaves the &"barrel" group, so the HUD's clear-the-lobby tally counts it
|
|
||||||
# exactly once.
|
|
||||||
func _smash_barrels_in_radius(range_m: float) -> void:
|
|
||||||
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
|
|
||||||
if not _barrel_alive(barrel):
|
|
||||||
continue
|
|
||||||
var to_b: Vector3 = (barrel as Node3D).global_position - global_position
|
|
||||||
to_b.y = 0.0
|
|
||||||
if to_b.length() <= range_m:
|
|
||||||
barrel.call(&"destroy_barrel")
|
|
||||||
|
|
||||||
|
|
||||||
# True while the boulder roll is live — matadors defer their charge-gore to the roll's own pop
|
|
||||||
# (is_rolling guard in matador._take_bull_charge) so the ball pops instead of quietly goring.
|
|
||||||
func is_rolling() -> bool:
|
|
||||||
return _active_ability == 3 and _ability_active
|
|
||||||
|
|
||||||
|
|
||||||
# True while the dash ability is live. A dash is a committed lunge — it gores/rams on contact
|
|
||||||
# regardless of angle (enemies bypass their horns-first cone while it's set).
|
|
||||||
func is_dashing() -> bool:
|
|
||||||
return _active_ability == 1 and _ability_active
|
|
||||||
|
|
||||||
|
|
||||||
# End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to the normal
|
|
||||||
# locomotion path next frame.
|
|
||||||
func _end_roll() -> void:
|
|
||||||
_ability_active = false
|
|
||||||
_active_ability = -1
|
|
||||||
_ability_timer = 0.0
|
|
||||||
_roll_ramp = 0.0
|
|
||||||
_roll_target = null
|
|
||||||
_roll_chains_left = 0
|
|
||||||
_roll_hit.clear()
|
|
||||||
_legs.set(&"charge_pitch_target", 0.0)
|
|
||||||
_legs.set(&"tail_ragdoll", false)
|
|
||||||
_legs.set(&"external_rotation", false)
|
|
||||||
if _roll_spark_emitter:
|
|
||||||
_roll_spark_emitter.emitting = false
|
|
||||||
if _charge_trail_emitter:
|
|
||||||
_charge_trail_emitter.emitting = false
|
|
||||||
velocity.x *= 0.15
|
|
||||||
velocity.z *= 0.15
|
|
||||||
_play_idle()
|
|
||||||
|
|
||||||
|
|
||||||
func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float,
|
|
||||||
dir: Vector3 = Vector3.ZERO) -> void:
|
|
||||||
var raw := cube_guy.global_transform.basis.z
|
|
||||||
var forward := dir if dir.length_squared() > 0.01 else Vector3(raw.x, 0.0, raw.z).normalized()
|
|
||||||
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
||||||
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
|
||||||
to_mat.y = 0.0
|
|
||||||
var dist := to_mat.length()
|
|
||||||
if dist > range_m:
|
|
||||||
continue
|
|
||||||
if dist > 0.01 and forward.dot(to_mat / dist) < cos(half_angle_rad):
|
|
||||||
continue
|
|
||||||
mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength)
|
|
||||||
|
|
||||||
|
|
||||||
func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0) -> void:
|
|
||||||
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
|
|
||||||
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
|
|
||||||
to_mat.y = 0.0
|
|
||||||
var dist := to_mat.length()
|
|
||||||
if dist > range_m:
|
|
||||||
continue
|
|
||||||
var away := to_mat / dist if dist > 0.01 \
|
|
||||||
else Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized()
|
|
||||||
mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Blood-spurt particle helpers ──────────────────────────────────────────────
|
|
||||||
# The ability effects now live in systems/fx/; only the spear-wound blood below is still built in
|
|
||||||
# code. Shared material, built once so the mobile driver compiles this particle shader a single
|
|
||||||
# time (colour comes per-particle from the colour_ramp via vertex colour, not this material).
|
|
||||||
func _unshaded_material() -> StandardMaterial3D:
|
|
||||||
if _unshaded_mat == null:
|
|
||||||
_unshaded_mat = StandardMaterial3D.new()
|
|
||||||
_unshaded_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
_unshaded_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
||||||
_unshaded_mat.vertex_color_use_as_albedo = true
|
|
||||||
_unshaded_mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
||||||
return _unshaded_mat
|
|
||||||
|
|
||||||
|
|
||||||
# Low-poly particle-sphere mesh (height = diameter), cached per size so repeats reuse one GPU upload.
|
|
||||||
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
|
|
||||||
var key := "%.4f:%d:%d" % [radius, segments, rings]
|
|
||||||
var cached: SphereMesh = _particle_sphere_cache.get(key)
|
|
||||||
if cached != null:
|
|
||||||
return cached
|
|
||||||
var sphere := SphereMesh.new()
|
|
||||||
sphere.radius = radius
|
|
||||||
sphere.height = radius * 2.0
|
|
||||||
sphere.radial_segments = segments
|
|
||||||
sphere.rings = rings
|
|
||||||
sphere.material = mat
|
|
||||||
_particle_sphere_cache[key] = sphere
|
|
||||||
return sphere
|
|
||||||
|
|
||||||
|
|
||||||
# Queue-free a transient FX node once its particles have finished.
|
|
||||||
func _free_after(node: Node, delay: float) -> void:
|
|
||||||
get_tree().create_timer(delay).timeout.connect(func() -> void:
|
|
||||||
if is_instance_valid(node):
|
|
||||||
node.queue_free()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
func _spawn_slam_fx() -> void:
|
|
||||||
var origin := global_position + Vector3(0.0, -0.5, 0.0)
|
|
||||||
_impact_fx.call(&"slam_rings", origin, DP.f("slam_range"))
|
|
||||||
Fx.spawn(&"bull_slam", Transform3D(Basis(), origin))
|
|
||||||
camera_pivot.call(&"trigger_hit", 0.60)
|
|
||||||
|
|
||||||
|
|
||||||
func _spawn_kick_fx(back_dir: Vector3) -> void:
|
|
||||||
var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0)
|
|
||||||
Fx.spawn(&"bull_kick", _aimed_xform(origin, back_dir))
|
|
||||||
_impact_fx.call(&"kick_flash", origin)
|
|
||||||
camera_pivot.call(&"trigger_hit", 0.35)
|
|
||||||
|
|
||||||
|
|
||||||
func _spawn_dash_burst() -> void:
|
|
||||||
Fx.spawn(&"bull_dash", _aimed_xform(global_position + Vector3(0.0, 0.2, 0.0), _dash_dir))
|
|
||||||
|
|
||||||
|
|
||||||
# A transform at `origin` whose local -Z faces `forward` (flattened to horizontal). The kick/dash
|
|
||||||
# effects bake their spray in local space, so aiming the spawn transform points the burst the
|
|
||||||
# right way. Falls back to an unrotated transform when `forward` has no horizontal component.
|
|
||||||
func _aimed_xform(origin: Vector3, forward: Vector3) -> Transform3D:
|
|
||||||
var flat := Vector3(forward.x, 0.0, forward.z)
|
|
||||||
if flat.length_squared() < 0.0001:
|
|
||||||
return Transform3D(Basis(), origin)
|
|
||||||
return Transform3D(Basis(), origin).looking_at(origin + flat.normalized(), Vector3.UP)
|
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
|
||||||
# ── Dead bull ─────────────────────────────────────────────────────────────
|
|
||||||
# The run is over the instant HP hits zero, but the lose screen holds off a
|
|
||||||
# beat before it appears (see HUD._show_game_over). Freeze the carcass through
|
|
||||||
# that delay — no input, no abilities, just coast to a stop under gravity — so a
|
|
||||||
# dead bull can't keep charging around while the screen is pending.
|
|
||||||
if _dead:
|
|
||||||
velocity.x = move_toward(velocity.x, 0.0, 40.0 * delta)
|
|
||||||
velocity.z = move_toward(velocity.z, 0.0, 40.0 * delta)
|
|
||||||
if not is_on_floor():
|
|
||||||
velocity += get_gravity() * delta
|
|
||||||
move_and_slide()
|
|
||||||
return
|
|
||||||
|
|
||||||
# ── Gravity ───────────────────────────────────────────────────────────────
|
|
||||||
if not is_on_floor():
|
|
||||||
var gravity := get_gravity()
|
|
||||||
# Crash a slam down hard: full leap on the way up, accelerated descent so
|
|
||||||
# the bull spends less time floating before the impact.
|
|
||||||
if _slam_pending and velocity.y < 0.0:
|
|
||||||
gravity *= DP.f("slam_fall_mult")
|
|
||||||
velocity += gravity * delta
|
|
||||||
|
|
||||||
# ── Camera-relative input direction ───────────────────────────────────────
|
|
||||||
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
|
||||||
var cam_basis := camera_pivot.global_transform.basis
|
|
||||||
var flat_forward := -Vector3(cam_basis.z.x, 0.0, cam_basis.z.z).normalized()
|
|
||||||
var flat_right := Vector3(cam_basis.x.x, 0.0, cam_basis.x.z).normalized()
|
|
||||||
var direction := (flat_forward * -input_dir.y + flat_right * input_dir.x).normalized()
|
|
||||||
|
|
||||||
var on_floor := is_on_floor()
|
|
||||||
|
|
||||||
# ── Ability cooldowns ────────────────────────────────────────────────────────
|
|
||||||
_hit_iframes = maxf(0.0, _hit_iframes - delta)
|
|
||||||
for i: int in 4:
|
|
||||||
ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
|
|
||||||
if _ability_active:
|
|
||||||
_ability_timer -= delta
|
|
||||||
if _ability_timer <= 0.0:
|
|
||||||
if _active_ability == 3:
|
|
||||||
_end_roll() # tears down roll-specific state (sparks, tumble control)
|
|
||||||
else:
|
|
||||||
_ability_active = false
|
|
||||||
_active_ability = -1
|
|
||||||
_legs.set(&"charge_pitch_target", 0.0)
|
|
||||||
_legs.set(&"tail_ragdoll", false)
|
|
||||||
_slam_pending = false
|
|
||||||
_play_idle()
|
|
||||||
|
|
||||||
# ── Boulder roll ──────────────────────────────────────────────────────────
|
|
||||||
# While rolling, the ability owns movement entirely (its own accel, low friction
|
|
||||||
# and wall ricochet) — bypass the normal input/kick/damping path below.
|
|
||||||
if _active_ability == 3 and _ability_active:
|
|
||||||
_tick_roll(delta, direction)
|
|
||||||
return
|
|
||||||
|
|
||||||
_was_on_floor = on_floor
|
|
||||||
|
|
||||||
# ── Ground locomotion — smooth accelerate / friction ──────────────────────
|
|
||||||
# WASD/arrows drive the bull directly: hold a direction to ramp up to max_speed
|
|
||||||
# and steer, release to coast to a stop. Ability bursts (dash) and wall bounces
|
|
||||||
# can exceed max_speed — while a direction is held we steer that momentum without
|
|
||||||
# braking it (cap tracks the current speed); friction only bleeds the excess once
|
|
||||||
# input is released.
|
|
||||||
var flat_vel := Vector3(velocity.x, 0.0, velocity.z)
|
|
||||||
var max_speed := DP.f("max_speed")
|
|
||||||
if _active_ability == 1 and _ability_active:
|
|
||||||
# Dash: leave the barrel fast like a bullet, then bleed exponentially back
|
|
||||||
# down to cruise speed (fastest drop right at the start), so it settles into
|
|
||||||
# normal running by the time the clip ends instead of holding top speed.
|
|
||||||
_dash_speed_cur = lerpf(_dash_speed_cur, max_speed, 1.0 - exp(-DP.f("dash_decel") * delta))
|
|
||||||
flat_vel = _dash_dir * _dash_speed_cur
|
|
||||||
elif direction.length_squared() > 0.01:
|
|
||||||
var cap := maxf(max_speed, flat_vel.length())
|
|
||||||
flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta)
|
|
||||||
else:
|
|
||||||
flat_vel = flat_vel.move_toward(Vector3.ZERO, DP.f("move_friction") * delta)
|
|
||||||
velocity.x = flat_vel.x
|
|
||||||
velocity.z = flat_vel.z
|
|
||||||
|
|
||||||
# ── Visual rotation follows velocity direction ─────────────────────────────
|
|
||||||
var flat_speed := Vector2(velocity.x, velocity.z).length()
|
|
||||||
if flat_speed > 0.4:
|
|
||||||
var target_angle := atan2(velocity.x, velocity.z)
|
|
||||||
cube_guy.rotation.y = lerp_angle(
|
|
||||||
cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed"))
|
|
||||||
|
|
||||||
# Looping WALK clip while genuinely moving on the ground, idle otherwise.
|
|
||||||
_update_locomotion_anim(on_floor and flat_speed > 1.0)
|
|
||||||
|
|
||||||
_pre_slide_vel_y = velocity.y
|
|
||||||
var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z)
|
|
||||||
move_and_slide()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── Wall bounce — no sliding, guaranteed exit ────────────────────────────
|
|
||||||
# pre_wall_vel is captured before move_and_slide strips the into-wall component,
|
|
||||||
# so into_spd correctly reflects how hard the bull hit the wall even when stationary.
|
|
||||||
for i in get_slide_collision_count():
|
|
||||||
var col := get_slide_collision(i)
|
|
||||||
var normal := col.get_normal()
|
|
||||||
if abs(normal.y) < 0.5:
|
|
||||||
var flat_n := Vector3(normal.x, 0.0, normal.z).normalized()
|
|
||||||
var into_spd := -pre_wall_vel.dot(flat_n)
|
|
||||||
var exit_spd := maxf(into_spd * DP.f("wall_bounce_restitution"),
|
|
||||||
DP.f("wall_min_bounce_speed"))
|
|
||||||
velocity.x = flat_n.x * exit_spd
|
|
||||||
velocity.z = flat_n.z * exit_spd
|
|
||||||
pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z)
|
|
||||||
|
|
||||||
# Ram straight through a barrel that formed the wall we bounced off.
|
|
||||||
var hit := col.get_collider()
|
|
||||||
if hit != null and hit.has_method(&"destroy_barrel"):
|
|
||||||
hit.call(&"destroy_barrel")
|
|
||||||
|
|
||||||
# ── Landing. Don't bounce ─────────────────────────────────────────────────────
|
|
||||||
if is_on_floor() and not _was_on_floor:
|
|
||||||
velocity.y = 0.0
|
|
||||||
|
|
||||||
# ── Slam landing ──────────────────────────────────────────────────────────
|
|
||||||
if _slam_pending and is_on_floor() and not _was_on_floor:
|
|
||||||
_slam_pending = false
|
|
||||||
_ability_active = false
|
|
||||||
_active_ability = -1
|
|
||||||
_ability_timer = 0.0
|
|
||||||
_legs.set(&"charge_pitch_target", 0.0)
|
|
||||||
_legs.set(&"tail_ragdoll", false)
|
|
||||||
_play_idle()
|
|
||||||
_spawn_slam_fx()
|
|
||||||
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up"))
|
|
||||||
_smash_barrels_in_radius(DP.f("slam_range"))
|
|
||||||
|
|
||||||
# ── Dust ──────────────────────────────────────────────────────────────────
|
|
||||||
if flat_speed > DP.f("dust_charge_spd") and on_floor:
|
|
||||||
_set_dust_state(DustState.CHARGE)
|
|
||||||
elif flat_speed > DP.f("dust_walk_spd") and on_floor:
|
|
||||||
_set_dust_state(DustState.WALK)
|
|
||||||
else:
|
|
||||||
_set_dust_state(DustState.NONE)
|
|
||||||
|
|
||||||
# ── Charge trail direction ────────────────────────────────────────────────
|
|
||||||
if _dust_state == DustState.CHARGE and _charge_trail_emitter:
|
|
||||||
var vel_flat := Vector3(velocity.x, 0.0, velocity.z)
|
|
||||||
if vel_flat.length_squared() > 0.1:
|
|
||||||
_charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized()
|
|
||||||
|
|
||||||
|
|
||||||
func get_hp() -> int:
|
|
||||||
return _hp
|
|
||||||
|
|
||||||
|
|
||||||
func get_max_hp() -> int:
|
|
||||||
return _max_hp
|
|
||||||
|
|
||||||
|
|
||||||
# Bonus pips currently on the bar — the HUD colours this many top pips yellow.
|
|
||||||
func get_bonus_hp() -> int:
|
|
||||||
return _bonus_hp
|
|
||||||
|
|
||||||
|
|
||||||
# Award extra health (from a lobby reward): grows both the cap and the current bar so the
|
|
||||||
# new pips arrive full, and marks them as bonus so the HUD paints them yellow.
|
|
||||||
func grant_bonus_hp(amount: int) -> void:
|
|
||||||
if amount <= 0:
|
|
||||||
return
|
|
||||||
_bonus_hp += amount
|
|
||||||
_max_hp += amount
|
|
||||||
_hp += amount
|
|
||||||
health_changed.emit(_hp, _max_hp)
|
|
||||||
|
|
||||||
|
|
||||||
# A clean hit — thrown or swung blade — costs the bull one HP pip; the run ends only
|
|
||||||
# when the last pip is gone. `cause` ("gored" / "thrown") tags how it happened for the
|
|
||||||
# death notice. I-frames absorb a flurry so several blades in one pass drop one pip,
|
|
||||||
# not the whole bar; `_dead` latches so the lose screen is raised exactly once.
|
|
||||||
func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void:
|
|
||||||
if _dead or _hit_iframes > 0.0:
|
|
||||||
return
|
|
||||||
_hp -= 1
|
|
||||||
_hit_flash()
|
|
||||||
_hit_iframes = DP.f("bull_hit_iframes")
|
|
||||||
health_changed.emit(_hp, _max_hp)
|
|
||||||
hit_taken.emit(cause)
|
|
||||||
camera_pivot.call(&"trigger_hit", 0.7)
|
|
||||||
# Vibrate controller 0 with half weak strength, full strong strength, for 0.5 seconds
|
|
||||||
if DP.b("gamepad_vibration"):
|
|
||||||
Input.start_joy_vibration(0, 0.5, 1, 0.2)
|
|
||||||
|
|
||||||
# A spear gores a spot — blood spurts from the entry wound (where it struck the
|
|
||||||
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
|
|
||||||
if cause == "thrown" and Settings.gore:
|
|
||||||
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
|
|
||||||
if _huff_player:
|
|
||||||
_huff_player.pitch_scale = randf_range(0.7, 0.9)
|
|
||||||
_huff_player.play()
|
|
||||||
if _hp <= 0:
|
|
||||||
if _bull_anim.current_animation != _ANIM_DEATH:
|
|
||||||
_bull_anim.speed_scale = 0.8
|
|
||||||
_bull_anim.play(_ANIM_DEATH)
|
|
||||||
_dead = true
|
|
||||||
died.emit(cause)
|
|
||||||
|
|
||||||
|
|
||||||
# Pop the bull upward — a smash hitbox knocks it off its feet. Only ever raises the
|
|
||||||
# vertical speed so a downward-moving bull still gets launched.
|
|
||||||
func apply_knock_up(v: float) -> void:
|
|
||||||
velocity.y = maxf(velocity.y, v)
|
|
||||||
|
|
||||||
|
|
||||||
# Typed entry point for the unified hit pipeline: a Hitbox (or any attacker) hands the bull a
|
|
||||||
# DamageEvent instead of calling take_sword_hit directly. A thin adapter over the existing pip /
|
|
||||||
# knock-up logic, so behaviour is unchanged while callers migrate off the stringly path.
|
|
||||||
func receive_damage(event: DamageEvent) -> void:
|
|
||||||
take_sword_hit(event.cause_name(), event.hit_position)
|
|
||||||
if event.knock_up > 0.0:
|
|
||||||
apply_knock_up(event.knock_up)
|
|
||||||
|
|
||||||
|
|
||||||
# Blood spurt from a spear wound: a short-lived spray of dark-red gobs flung out of the
|
|
||||||
# entry point, biased outward from the bull's centre (so it reads as bursting from the
|
|
||||||
# hide) with a touch of lift before gravity drags it down.
|
|
||||||
func _spawn_blood(hit_pos: Vector3) -> void:
|
|
||||||
var out := hit_pos - global_position
|
|
||||||
out.y = 0.0
|
|
||||||
out = out.normalized() if out.length() > 0.05 else Vector3(0.0, 0.0, 1.0)
|
|
||||||
var dir := (out + Vector3.UP * 0.8).normalized()
|
|
||||||
|
|
||||||
var sphere := _particle_sphere(0.05, 4, 2, _unshaded_material())
|
|
||||||
|
|
||||||
var ramp := Gradient.new()
|
|
||||||
ramp.set_color(0, Color(0.55, 0.02, 0.02, 1.0))
|
|
||||||
ramp.set_color(1, Color(0.28, 0.0, 0.0, 0.0))
|
|
||||||
|
|
||||||
var scale_curve := Curve.new()
|
|
||||||
scale_curve.add_point(Vector2(0.0, 1.0))
|
|
||||||
scale_curve.add_point(Vector2(0.6, 0.7))
|
|
||||||
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
||||||
|
|
||||||
var p := CPUParticles3D.new()
|
|
||||||
p.one_shot = true
|
|
||||||
p.explosiveness = 0.9
|
|
||||||
p.amount = 34
|
|
||||||
p.lifetime = 0.7
|
|
||||||
p.randomness = 0.5
|
|
||||||
p.local_coords = false
|
|
||||||
p.direction = dir
|
|
||||||
p.spread = 42.0
|
|
||||||
p.gravity = Vector3(0.0, -14.0, 0.0)
|
|
||||||
p.initial_velocity_min = 3.5
|
|
||||||
p.initial_velocity_max = 8.0
|
|
||||||
p.scale_amount_min = 0.5
|
|
||||||
p.scale_amount_max = 1.6
|
|
||||||
p.mesh = sphere
|
|
||||||
p.color_ramp = ramp
|
|
||||||
p.scale_amount_curve = scale_curve
|
|
||||||
get_parent().add_child(p)
|
|
||||||
p.global_position = hit_pos
|
|
||||||
p.restart()
|
|
||||||
_free_after(p, 1.5)
|
|
||||||
|
|
||||||
|
|
||||||
func _hit_flash() -> void:
|
|
||||||
var meshes := find_children("*", "MeshInstance3D", true, false)
|
|
||||||
|
|
||||||
for node in meshes:
|
|
||||||
var mesh := node as MeshInstance3D
|
|
||||||
if mesh:
|
|
||||||
mesh.material_overlay = _make_hit_overlay()
|
|
||||||
|
|
||||||
if _hit_flash_tween:
|
|
||||||
_hit_flash_tween.kill()
|
|
||||||
|
|
||||||
_hit_flash_tween = create_tween()
|
|
||||||
_hit_flash_tween.tween_interval(0.08)
|
|
||||||
_hit_flash_tween.tween_callback(_clear_hit_flash)
|
|
||||||
|
|
||||||
|
|
||||||
func _make_hit_overlay() -> StandardMaterial3D:
|
|
||||||
var mat := StandardMaterial3D.new()
|
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
||||||
mat.albedo_color = Color(1.0, 0.03, 0.03, 1.0)
|
|
||||||
return mat
|
|
||||||
|
|
||||||
|
|
||||||
func _clear_hit_flash() -> void:
|
|
||||||
var meshes := find_children("*", "MeshInstance3D", true, false)
|
|
||||||
|
|
||||||
for node in meshes:
|
|
||||||
var mesh := node as MeshInstance3D
|
|
||||||
if mesh:
|
|
||||||
mesh.material_overlay = null
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
[gd_scene format=3 uid="uid://crtm0xtiql3r8"]
|
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://bn777gguclud5" path="res://actors/player/player.gd" id="1_xhfnw"]
|
|
||||||
[ext_resource type="Script" uid="uid://cpyq2r5cq653g" path="res://actors/player/camera_iso.gd" id="2_camera_iso"]
|
|
||||||
[ext_resource type="PackedScene" uid="uid://bun14xmk20pcj" path="res://art/characters/bull.fbx" id="4_e80uo"]
|
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_kne1u"]
|
|
||||||
radius = 0.7495117
|
|
||||||
height = 1.845398
|
|
||||||
|
|
||||||
[node name="Player" type="CharacterBody3D" unique_id=165510044]
|
|
||||||
collision_layer = 2
|
|
||||||
collision_mask = 9
|
|
||||||
script = ExtResource("1_xhfnw")
|
|
||||||
|
|
||||||
[node name="CollisionBull" type="CollisionShape3D" parent="." unique_id=571780801]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.29183745, 0.3197594)
|
|
||||||
shape = SubResource("CapsuleShape3D_kne1u")
|
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="." unique_id=1120161087]
|
|
||||||
script = ExtResource("2_camera_iso")
|
|
||||||
|
|
||||||
[node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
|
|
||||||
|
|
||||||
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1057197468) index="0" unique_id=840295370]
|
|
||||||
bones/0/position = Vector3(0, 0, 0.009963839)
|
|
||||||
bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
|
|
||||||
bones/0/scale = Vector3(1, 1, 1)
|
|
||||||
bones/1/position = Vector3(0.003526625, -0.0043296535, 0.0040827994)
|
|
||||||
bones/1/rotation = Quaternion(-0.00038647873, 0.06909673, 0.9868542, -0.14609687)
|
|
||||||
bones/2/rotation = Quaternion(-0.06929122, 3.2175163e-07, -0.112670615, 0.99121344)
|
|
||||||
bones/4/position = Vector3(0.0045358227, -0.002137695, -0.004238624)
|
|
||||||
bones/4/rotation = Quaternion(-0.24948503, 0.19590352, 0.9416978, -0.11217985)
|
|
||||||
bones/4/scale = Vector3(1.0000001, 0.9999999, 1.0000001)
|
|
||||||
bones/5/rotation = Quaternion(-0.58943665, -0.3660725, -0.22564478, 0.683842)
|
|
||||||
bones/5/scale = Vector3(1, 1.0000001, 0.99999994)
|
|
||||||
bones/6/rotation = Quaternion(0.52910197, 0.15997827, 0.120245, 0.8246207)
|
|
||||||
bones/6/scale = Vector3(1, 1, 1.0000001)
|
|
||||||
bones/17/position = Vector3(0, 0.0009373814, 0.007552617)
|
|
||||||
bones/17/rotation = Quaternion(-9.4156974e-08, 0.7071068, 0.7071067, -9.4156974e-08)
|
|
||||||
bones/19/position = Vector3(-0.003526625, -0.0043296535, 0.0040827994)
|
|
||||||
bones/19/rotation = Quaternion(0.00038647873, 0.06909673, 0.9868542, 0.14609687)
|
|
||||||
bones/20/rotation = Quaternion(-0.06929122, -3.2227587e-07, 0.11267061, 0.99121344)
|
|
||||||
bones/22/position = Vector3(-0.0045358227, -0.002137695, -0.004238624)
|
|
||||||
bones/22/rotation = Quaternion(0.24948503, 0.19590352, 0.9416978, 0.11217985)
|
|
||||||
bones/23/rotation = Quaternion(-0.58943665, 0.3660725, 0.22564478, 0.683842)
|
|
||||||
bones/23/scale = Vector3(1, 1.0000001, 1)
|
|
||||||
bones/24/rotation = Quaternion(0.529102, -0.15997836, -0.120245025, 0.82462066)
|
|
||||||
bones/24/scale = Vector3(1, 1.0000001, 1)
|
|
||||||
|
|
||||||
[editable path="bull"]
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
extends Node3D
|
|
||||||
|
|
||||||
var player: Node3D = null
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
|
||||||
# If we don't have the player yet, try to find them in the group
|
|
||||||
if not is_instance_valid(player):
|
|
||||||
var players = get_tree().get_nodes_in_group("player")
|
|
||||||
if players.size() > 0:
|
|
||||||
player = players[0] as Node3D
|
|
||||||
|
|
||||||
# If the player exists, follow their position
|
|
||||||
if is_instance_valid(player):
|
|
||||||
global_position = player.global_position
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cmapsaj010q40
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[gd_scene format=3 uid="uid://4v25hb0qu5l0"]
|
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://cmapsaj010q40" path="res://actors/player/player_follow_light.gd" id="1_w7wrt"]
|
|
||||||
|
|
||||||
[node name="PlayerFollowLight" type="Node3D" unique_id=1889971494]
|
|
||||||
script = ExtResource("1_w7wrt")
|
|
||||||
|
|
||||||
[node name="SpotLight3D" type="SpotLight3D" parent="." unique_id=1523840927]
|
|
||||||
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, 0, 2.6136062, 1.0431258e-07)
|
|
||||||
light_color = Color(0.9999998, 0.8702623, 0.60548127, 1)
|
|
||||||
light_energy = 8.0
|
|
||||||
shadow_enabled = true
|
|
||||||
distance_fade_length = 30.45
|
|
||||||
spot_angle = 72.62192
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Contribution
|
|
||||||
|
|
||||||
Hello, thanks for taking time and helping out with the addon!
|
|
||||||
|
|
||||||
Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. Please sign the CLA before sending the PR: https://www.jetbrains.com/agreements/cla/.
|
|
||||||
|
|
||||||
#### Local setup
|
|
||||||
|
|
||||||
Open the `godot-editor-plugin/CMakeLists.txt` in Rider 2026.1+, select `rider-gdextension` target in the run configuration selector.
|
|
||||||
There are some more fixes coming in 2026.2, which will allow working on gdscript and cpp files within the same workspace.
|
|
||||||
|
|
||||||
#### Signing the binaries
|
|
||||||
|
|
||||||
Plugin binaries need to be signed to comply with modern operating system security requirements. Unsigned dynamic libraries may fail to load or trigger security warnings:
|
|
||||||
|
|
||||||
- **macOS**: Requires code signing (and often notarization) for `.dylib` files. Unsigned libraries may simply fail to load.
|
|
||||||
- **Windows**: SmartScreen may block unsigned binaries, and corporate environments often enforce signed code only.
|
|
||||||
- **Linux**: Generally allows unsigned `.so` files, but some sandboxed environments (Flatpak, Snap) impose restrictions.
|
|
||||||
|
|
||||||
**How signing is implemented:**
|
|
||||||
|
|
||||||
1. **TeamCity Configuration**: [ijplatform_master_Net_Deploy_Plugins_Public_Godot](https://buildserver.labs.intellij.net/buildConfiguration/ijplatform_master_Net_Deploy_Plugins_Public_Godot) (internal JetBrains link)
|
|
||||||
- Note: The automatic trigger on new tags is currently not working, so manual triggering is required.
|
|
||||||
|
|
||||||
2. **Release Process**:
|
|
||||||
- First, use your GitHub Action to prepare a **pre-release** with a tag
|
|
||||||
- Manually trigger the TeamCity configuration on the necessary branch/tag
|
|
||||||
- The configuration will download assets, sign them, and upload them back (removing the pre-release flag)
|
|
||||||
- The pre-release flag is required at the start; otherwise, the signing configuration will skip the release (protection against double signing)
|
|
||||||
|
|
||||||
3. **Signing existing releases**:
|
|
||||||
- You can run signing on already existing old releases/tags
|
|
||||||
- Mark them as pre-release first, then run the configuration on the necessary tag
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 JetBrains
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# JetBrains Rider Integration – Godot addon
|
|
||||||
|
|
||||||
This addon currently provides two features:
|
|
||||||
1. Finds all Rider installations in the system and provides a selector on the `Text Editor` -> `External` tab in the settings to select one.
|
|
||||||
2. Provides the "Use Rider" toggle in the Godot toolbar and, when enabled, applies a set of editor settings recommended for working with JetBrains Rider. The goal is to make it trivial to switch between Rider‑optimized settings and stock Godot settings with a single click.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Godot 4.2.2+
|
|
||||||
|
|
||||||
Install:
|
|
||||||
1. Inside the Godot editor, it can be installed from the AssetLib view or [downloaded](https://godotengine.org/asset-library/asset/4576)
|
|
||||||
2. [Optional] Change the initial value of `active` in the plugin.cfg
|
|
||||||
3. [Optional] Change the initial values in the presets.json file.
|
|
||||||
4. Enable "JetBrains Rider External Editor" plugin in the Project → Project Settings… → Plugins tab.
|
|
||||||
|
|
||||||
Use:
|
|
||||||
- A toolbar toggle named "Use Rider" will appear. Click it to turn the preset On/Off.
|
|
||||||
|
|
||||||
Screenshot:
|
|
||||||

|
|
||||||
|
|
||||||
## What the toggle changes
|
|
||||||
|
|
||||||
The preset values live in `presets.json`.
|
|
||||||
|
|
||||||
When ON:
|
|
||||||
- Write the values from the "on" preset into the Editor Settings.
|
|
||||||
|
|
||||||
When OFF:
|
|
||||||
- Write the values from the "off" preset into the Editor Settings.
|
|
||||||
|
|
||||||
Note: The plugin does not currently auto‑set Rider’s executable path or flags. See Plans below.
|
|
||||||
|
|
||||||
## Setting Rider to be the external editor
|
|
||||||
|
|
||||||
The plugin automatically detects installed Rider versions on your system and provides a convenient dropdown menu to
|
|
||||||
select which installation to use as your external editor.
|
|
||||||
|
|
||||||
- The plugin scans common installation locations for Rider on Windows, macOS, and Linux.
|
|
||||||
- Detected installations appear in the "Select Rider" dropdown in the toolbar.
|
|
||||||
- When you select a Rider installation, the plugin automatically updates the `dotnet/editor/external_editor_path` editor
|
|
||||||
setting.
|
|
||||||
|
|
||||||
## License
|
|
||||||
See `addons/rider-plugin/LICENCE`.
|
|
||||||
|
|
||||||
## Acknowledgements
|
|
||||||
Created by JetBrains to streamline using Rider with Godot.
|
|
||||||
Initial idea https://github.com/sszigeti/toggle_external_editor
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +0,0 @@
|
|||||||
[configuration]
|
|
||||||
|
|
||||||
entry_symbol = "rider_library_init"
|
|
||||||
compatibility_minimum = "4.2.2"
|
|
||||||
|
|
||||||
[libraries]
|
|
||||||
|
|
||||||
macos.single.debug = "./macos/librider-gdextension.macos.editor.dylib"
|
|
||||||
|
|
||||||
windows.arm64.single.debug = "./windows/rider-gdextension.windows.editor.arm64.dll"
|
|
||||||
|
|
||||||
windows.x86_64.single.debug = "./windows/rider-gdextension.windows.editor.x86_64.dll"
|
|
||||||
|
|
||||||
linux.x86_64.single.debug = "./linux/librider-gdextension.linux.editor.x86_64.so"
|
|
||||||
|
|
||||||
linux.arm64.single.debug = "./linux/librider-gdextension.linux.editor.arm64.so"
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1,144 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
|
|
||||||
<svg
|
|
||||||
version="1.0"
|
|
||||||
id="katman_1"
|
|
||||||
x="0px"
|
|
||||||
y="0px"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xml:space="preserve"
|
|
||||||
sodipodi:docname="JetBrains Rider Icon.svg"
|
|
||||||
width="512"
|
|
||||||
height="512"
|
|
||||||
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
|
||||||
id="defs43" /><sodipodi:namedview
|
|
||||||
id="namedview41"
|
|
||||||
pagecolor="#505050"
|
|
||||||
bordercolor="#ffffff"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:pageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="1"
|
|
||||||
showgrid="false"
|
|
||||||
inkscape:zoom="1.0980553"
|
|
||||||
inkscape:cx="256.36233"
|
|
||||||
inkscape:cy="212.19333"
|
|
||||||
inkscape:window-width="1920"
|
|
||||||
inkscape:window-height="1027"
|
|
||||||
inkscape:window-x="-8"
|
|
||||||
inkscape:window-y="22"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="katman_1" />
|
|
||||||
<style
|
|
||||||
type="text/css"
|
|
||||||
id="style2">
|
|
||||||
.st0{fill:url(#SVGID_1_);}
|
|
||||||
.st1{fill:url(#SVGID_00000130622934180993703410000017420799098261276343_);}
|
|
||||||
.st2{fill:url(#SVGID_00000060739771362873723200000017991140373209755022_);}
|
|
||||||
.st3{fill:#FFFFFF;}
|
|
||||||
</style>
|
|
||||||
<symbol
|
|
||||||
id="rider"
|
|
||||||
viewBox="-35 -35 70 70">
|
|
||||||
<linearGradient
|
|
||||||
id="SVGID_1_"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="30.4897"
|
|
||||||
y1="5.1188998"
|
|
||||||
x2="-23.4683"
|
|
||||||
y2="-25.8451">
|
|
||||||
<stop
|
|
||||||
offset="0"
|
|
||||||
style="stop-color:#DD1265"
|
|
||||||
id="stop4" />
|
|
||||||
<stop
|
|
||||||
offset="0.483"
|
|
||||||
style="stop-color:#DD1265"
|
|
||||||
id="stop6" />
|
|
||||||
<stop
|
|
||||||
offset="0.942"
|
|
||||||
style="stop-color:#FDB60D"
|
|
||||||
id="stop8" />
|
|
||||||
</linearGradient>
|
|
||||||
<path
|
|
||||||
class="st0"
|
|
||||||
d="M 35,-7.7 -14.1,-35 18.8,13.9 25.5,9.5 Z"
|
|
||||||
id="path11" />
|
|
||||||
|
|
||||||
<linearGradient
|
|
||||||
id="SVGID_00000169517474296634577950000016895268102491994795_"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="-1.5839"
|
|
||||||
y1="-28.888"
|
|
||||||
x2="19.805099"
|
|
||||||
y2="30.174999">
|
|
||||||
<stop
|
|
||||||
offset="0.139"
|
|
||||||
style="stop-color:#087CFA"
|
|
||||||
id="stop13" />
|
|
||||||
<stop
|
|
||||||
offset="0.476"
|
|
||||||
style="stop-color:#DD1265"
|
|
||||||
id="stop15" />
|
|
||||||
<stop
|
|
||||||
offset="0.958"
|
|
||||||
style="stop-color:#087CFA"
|
|
||||||
id="stop17" />
|
|
||||||
</linearGradient>
|
|
||||||
<path
|
|
||||||
style="fill:url(#SVGID_00000169517474296634577950000016895268102491994795_)"
|
|
||||||
d="M 15.5,-18.9 9.3,-33.9 -4.3,-20.5 1.2,28.1 14.4,35 35,23 Z"
|
|
||||||
id="path20" />
|
|
||||||
|
|
||||||
<linearGradient
|
|
||||||
id="SVGID_00000135668350575375336630000014638141720088491653_"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="-17.5865"
|
|
||||||
y1="-27.071199"
|
|
||||||
x2="-1.7875"
|
|
||||||
y2="29.073799">
|
|
||||||
<stop
|
|
||||||
offset="0.278"
|
|
||||||
style="stop-color:#DD1265"
|
|
||||||
id="stop22" />
|
|
||||||
<stop
|
|
||||||
offset="0.968"
|
|
||||||
style="stop-color:#FDB60D"
|
|
||||||
id="stop24" />
|
|
||||||
</linearGradient>
|
|
||||||
<path
|
|
||||||
style="fill:url(#SVGID_00000135668350575375336630000014638141720088491653_)"
|
|
||||||
d="m -14.1,-35 -20.9,14.1 7.8,48.1 20.1,7.7 26,-21 z"
|
|
||||||
id="path27" />
|
|
||||||
<path
|
|
||||||
d="M 21,-21 H -21 V 21 H 21 Z"
|
|
||||||
id="path29" />
|
|
||||||
<path
|
|
||||||
class="st3"
|
|
||||||
d="m -0.6,13.6 h -15.8 v 2.7 h 15.8 z"
|
|
||||||
id="path31" />
|
|
||||||
<path
|
|
||||||
class="st3"
|
|
||||||
d="m 0.5,-15.8 h 6.2 c 5,0 8.4,3.4 8.4,7.7 0,4.4 -3.4,7.9 -8.4,7.9 L 0.5,0 Z m 3.5,3.2 v 9.5 h 2.7 c 2.8,0 4.8,-1.9 4.8,-4.6 0,-2.8 -1.9,-4.7 -4.8,-4.7 z"
|
|
||||||
id="path33" />
|
|
||||||
<path
|
|
||||||
class="st3"
|
|
||||||
d="m -15.7,-15.8 h 7.2 c 2,0 3.5,0.6 4.6,1.6 0.9,0.9 1.3,2.1 1.3,3.6 v 0.1 c 0,1.3 -0.3,2.3 -0.9,3.1 -0.6,0.8 -1.4,1.4 -2.4,1.8 L -2,0 h -4.1 l -3.3,-4.8 h -2.9 V 0 h -3.5 z m 7,7.7 c 0.8,0 1.5,-0.2 2,-0.6 0.5,-0.4 0.7,-1 0.7,-1.6 v -0.1 c 0,-0.8 -0.2,-1.3 -0.7,-1.7 -0.5,-0.3 -1.1,-0.6 -2,-0.6 h -3.4 v 4.5 h 3.4 z"
|
|
||||||
id="path35" />
|
|
||||||
</symbol>
|
|
||||||
<use
|
|
||||||
xlink:href="#rider"
|
|
||||||
width="70"
|
|
||||||
height="70"
|
|
||||||
x="-35"
|
|
||||||
y="-35"
|
|
||||||
transform="matrix(7.0682519,0,0,7.2481031,256,256)"
|
|
||||||
style="overflow:visible"
|
|
||||||
id="use38" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,43 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="texture"
|
|
||||||
type="CompressedTexture2D"
|
|
||||||
uid="uid://wrdrj6wednn8"
|
|
||||||
path="res://.godot/imported/icon.svg-45c914cff7482ba9564963fe65b548e4.ctex"
|
|
||||||
metadata={
|
|
||||||
"vram_texture": false
|
|
||||||
}
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://addons/rider-plugin/icons/icon.svg"
|
|
||||||
dest_files=["res://.godot/imported/icon.svg-45c914cff7482ba9564963fe65b548e4.ctex"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
compress/mode=0
|
|
||||||
compress/high_quality=false
|
|
||||||
compress/lossy_quality=0.7
|
|
||||||
compress/uastc_level=0
|
|
||||||
compress/rdo_quality_loss=0.0
|
|
||||||
compress/hdr_compression=1
|
|
||||||
compress/normal_map=0
|
|
||||||
compress/channel_pack=0
|
|
||||||
mipmaps/generate=false
|
|
||||||
mipmaps/limit=-1
|
|
||||||
roughness/mode=0
|
|
||||||
roughness/src_normal=""
|
|
||||||
process/channel_remap/red=0
|
|
||||||
process/channel_remap/green=1
|
|
||||||
process/channel_remap/blue=2
|
|
||||||
process/channel_remap/alpha=3
|
|
||||||
process/fix_alpha_border=true
|
|
||||||
process/premult_alpha=false
|
|
||||||
process/normal_map_invert_y=false
|
|
||||||
process/hdr_as_srgb=false
|
|
||||||
process/hdr_clamp_exposure=false
|
|
||||||
process/size_limit=0
|
|
||||||
detect_3d/compress_to=1
|
|
||||||
svg/scale=1.0
|
|
||||||
editor/scale_with_editor_scale=false
|
|
||||||
editor/convert_colors_with_editor_theme=false
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[plugin]
|
|
||||||
|
|
||||||
name="JetBrains Rider External Editor"
|
|
||||||
description="Provides a toolbar toggle, to switch a set of settings on and off. Default set of settings helps to enable/disable the following settings recommended by the Rider documentation https://www.jetbrains.com/help/rider/Godot.html#optimize-godot-editor-for-rider"
|
|
||||||
author="JetBrains"
|
|
||||||
version="1.0.0"
|
|
||||||
script="rider-plugin.gd"
|
|
||||||
|
|
||||||
[presets]
|
|
||||||
|
|
||||||
active="on"
|
|
||||||
presets="presets.json"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"on": {
|
|
||||||
"text_editor/external/use_external_editor": true,
|
|
||||||
"interface/editor/import_resources_when_unfocused": true,
|
|
||||||
"interface/editor/save_on_focus_loss": true,
|
|
||||||
"text_editor/behavior/files/auto_reload_scripts_on_external_change": true,
|
|
||||||
"run/window_placement/game_embed_mode": -1
|
|
||||||
},
|
|
||||||
"off": {
|
|
||||||
"text_editor/external/use_external_editor": false,
|
|
||||||
"interface/editor/import_resources_when_unfocused": false,
|
|
||||||
"interface/editor/save_on_focus_loss": false,
|
|
||||||
"text_editor/behavior/files/auto_reload_scripts_on_external_change": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends EditorPlugin
|
|
||||||
|
|
||||||
var editor_settings: EditorSettings
|
|
||||||
var checkbutton: CheckButton
|
|
||||||
var _preset_applier: PresetApplier
|
|
||||||
var _settings_service: EditorSettingsService
|
|
||||||
var _locator_service: RiderLocatorService
|
|
||||||
var _plugin_cfg_path: String
|
|
||||||
var _presets_json_path: String
|
|
||||||
|
|
||||||
func _enter_tree() -> void:
|
|
||||||
editor_settings = EditorInterface.get_editor_settings()
|
|
||||||
var script_path := (get_script() as Script).resource_path
|
|
||||||
var plugin_dir := script_path.get_base_dir()
|
|
||||||
_plugin_cfg_path = plugin_dir + "/plugin.cfg"
|
|
||||||
var cfg := ConfigFile.new()
|
|
||||||
var err := cfg.load(_plugin_cfg_path)
|
|
||||||
if err != OK:
|
|
||||||
push_warning("Failed to load plugin.cfg: %s" % [err])
|
|
||||||
return
|
|
||||||
var active_str := str(cfg.get_value("presets", "active", "on"))
|
|
||||||
var is_active := active_str == "on"
|
|
||||||
var presets_rel_path := str(cfg.get_value("presets", "presets", "presets.json"))
|
|
||||||
_presets_json_path = plugin_dir + "/" + presets_rel_path
|
|
||||||
|
|
||||||
# Build UI
|
|
||||||
checkbutton = CheckButton.new()
|
|
||||||
checkbutton.text = "Use Rider"
|
|
||||||
checkbutton.tooltip_text = "Shortcut for setting recommended settings"
|
|
||||||
checkbutton.button_pressed = is_active
|
|
||||||
checkbutton.pressed.connect(_on_checkbutton_pressed)
|
|
||||||
add_control_to_container(EditorPlugin.CONTAINER_TOOLBAR, checkbutton)
|
|
||||||
|
|
||||||
# Initialize services and panel
|
|
||||||
_settings_service = EditorSettingsService.new()
|
|
||||||
_locator_service = RiderLocatorService.new()
|
|
||||||
_preset_applier = PresetApplier.new(_presets_json_path)
|
|
||||||
|
|
||||||
_locator_service.add_selector_in_editor_interface(_settings_service)
|
|
||||||
|
|
||||||
# Ensure settings reflect current state on startup
|
|
||||||
_preset_applier.apply_preset(editor_settings, is_active)
|
|
||||||
|
|
||||||
func _on_checkbutton_pressed() -> void:
|
|
||||||
var cfg := ConfigFile.new()
|
|
||||||
if cfg.load(_plugin_cfg_path) != OK:
|
|
||||||
push_warning("Failed to load plugin.cfg to update state")
|
|
||||||
return
|
|
||||||
var is_active := checkbutton.button_pressed
|
|
||||||
var key := _preset_applier.get_preset_key(is_active)
|
|
||||||
cfg.set_value("presets", "active", key)
|
|
||||||
var save_err := cfg.save(_plugin_cfg_path)
|
|
||||||
if save_err != OK:
|
|
||||||
push_warning("Failed to save plugin.cfg: %s" % [save_err])
|
|
||||||
# Apply selected preset to editor settings
|
|
||||||
_preset_applier.apply_preset(editor_settings, is_active)
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
|
||||||
if checkbutton != null:
|
|
||||||
remove_control_from_container(EditorPlugin.CONTAINER_TOOLBAR, checkbutton)
|
|
||||||
checkbutton.queue_free()
|
|
||||||
|
|
||||||
var args = OS.get_cmdline_args()
|
|
||||||
if "--rider-addon-tests" in args:
|
|
||||||
print("==== rider-addon-tests finished ====")
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://c1x58xm2w1n20
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
@@ -1,40 +0,0 @@
|
|||||||
[remap]
|
|
||||||
|
|
||||||
importer="texture"
|
|
||||||
type="CompressedTexture2D"
|
|
||||||
uid="uid://bro2w3ghx4xt7"
|
|
||||||
path="res://.godot/imported/Toolbar.png-a521e2493bd3c08a829245b3129bb58f.ctex"
|
|
||||||
metadata={
|
|
||||||
"vram_texture": false
|
|
||||||
}
|
|
||||||
|
|
||||||
[deps]
|
|
||||||
|
|
||||||
source_file="res://addons/rider-plugin/screenshots/Toolbar.png"
|
|
||||||
dest_files=["res://.godot/imported/Toolbar.png-a521e2493bd3c08a829245b3129bb58f.ctex"]
|
|
||||||
|
|
||||||
[params]
|
|
||||||
|
|
||||||
compress/mode=0
|
|
||||||
compress/high_quality=false
|
|
||||||
compress/lossy_quality=0.7
|
|
||||||
compress/uastc_level=0
|
|
||||||
compress/rdo_quality_loss=0.0
|
|
||||||
compress/hdr_compression=1
|
|
||||||
compress/normal_map=0
|
|
||||||
compress/channel_pack=0
|
|
||||||
mipmaps/generate=false
|
|
||||||
mipmaps/limit=-1
|
|
||||||
roughness/mode=0
|
|
||||||
roughness/src_normal=""
|
|
||||||
process/channel_remap/red=0
|
|
||||||
process/channel_remap/green=1
|
|
||||||
process/channel_remap/blue=2
|
|
||||||
process/channel_remap/alpha=3
|
|
||||||
process/fix_alpha_border=true
|
|
||||||
process/premult_alpha=false
|
|
||||||
process/normal_map_invert_y=false
|
|
||||||
process/hdr_as_srgb=false
|
|
||||||
process/hdr_clamp_exposure=false
|
|
||||||
process/size_limit=0
|
|
||||||
detect_3d/compress_to=1
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
@tool
|
|
||||||
## Simple JSON helpers for editor/runtime use.
|
|
||||||
## Keeps file and JSON parsing concerns out of feature code.
|
|
||||||
class_name JsonUtils
|
|
||||||
|
|
||||||
static func load_from_file(path: String) -> Variant:
|
|
||||||
# Returns parsed JSON value (Dictionary/Array/etc.) or null on error.
|
|
||||||
var file := FileAccess.open(path, FileAccess.READ)
|
|
||||||
if file == null:
|
|
||||||
push_warning("JsonUtils: Failed to open file: %s" % path)
|
|
||||||
return null
|
|
||||||
var text := file.get_as_text()
|
|
||||||
file.close()
|
|
||||||
var data: Variant = JSON.parse_string(text)
|
|
||||||
if data == null:
|
|
||||||
push_warning("JsonUtils: Invalid JSON in file: %s" % path)
|
|
||||||
return null
|
|
||||||
return data
|
|
||||||
|
|
||||||
static func load_dict_from_file(path: String) -> Dictionary:
|
|
||||||
# Returns Dictionary or empty {} on error.
|
|
||||||
var data := load_from_file(path) as Dictionary
|
|
||||||
return data
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bci4kmk7h4j6a
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends RefCounted
|
|
||||||
class_name RiderLocatorService
|
|
||||||
|
|
||||||
var _installations_found: Array = []
|
|
||||||
var _thread: Thread = null
|
|
||||||
|
|
||||||
func get_installations() -> Array:
|
|
||||||
var result: Array = RiderLocator.new().get_installations() # from the gdextension
|
|
||||||
return result
|
|
||||||
|
|
||||||
# todo
|
|
||||||
func fix_external_editor_if_supplied_in_commandline(_settings_service: EditorSettingsService, editor_settings: EditorSettings) -> bool:
|
|
||||||
# When Godot is started from Rider (or vice versa), we may receive the Rider path
|
|
||||||
# via command-line so we can keep Godot's external editor setting in sync.
|
|
||||||
# Supported form (only this one):
|
|
||||||
# --my_rider_path="/absolute/path/to/rider with possible spaces"
|
|
||||||
var args : Array = OS.get_cmdline_args()
|
|
||||||
var provided_rider_path := ""
|
|
||||||
for a_raw in args:
|
|
||||||
var a: String = str(a_raw)
|
|
||||||
if a.begins_with("--my_rider_path="):
|
|
||||||
provided_rider_path = a.substr("--my_rider_path=".length())
|
|
||||||
break
|
|
||||||
|
|
||||||
if provided_rider_path.is_empty():
|
|
||||||
return false
|
|
||||||
|
|
||||||
provided_rider_path = trim_quotes(provided_rider_path)
|
|
||||||
|
|
||||||
# Validate existence (file or dir)
|
|
||||||
var looks_existing := FileAccess.file_exists(provided_rider_path) || DirAccess.dir_exists_absolute(provided_rider_path)
|
|
||||||
if looks_existing:
|
|
||||||
_settings_service.set_external_editor_path(editor_settings, provided_rider_path)
|
|
||||||
print("Rider path provided via CLI (my_rider_path): ", provided_rider_path)
|
|
||||||
return true
|
|
||||||
else:
|
|
||||||
push_warning("my_rider_path was provided but does not exist: %s" % [provided_rider_path])
|
|
||||||
return false
|
|
||||||
|
|
||||||
func trim_quotes(s: String) -> String:
|
|
||||||
if s.begins_with('"') and s.ends_with('"') and s.length() >= 2:
|
|
||||||
return s.substr(1, s.length() - 2)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
func add_selector_in_editor_interface(_settings_service: EditorSettingsService):
|
|
||||||
_update_selector(_installations_found)
|
|
||||||
|
|
||||||
if _installations_found.is_empty() and _thread == null:
|
|
||||||
_thread = Thread.new()
|
|
||||||
_thread.start(_load_installations)
|
|
||||||
|
|
||||||
func _load_installations() -> void:
|
|
||||||
var array: Array = get_installations()
|
|
||||||
call_deferred("_on_installations_loaded", array)
|
|
||||||
|
|
||||||
func _on_installations_loaded(array: Array):
|
|
||||||
_installations_found = array
|
|
||||||
if _thread:
|
|
||||||
_thread.wait_to_finish()
|
|
||||||
_thread = null
|
|
||||||
_update_selector(_installations_found)
|
|
||||||
|
|
||||||
func _notification(what: int) -> void:
|
|
||||||
if what == NOTIFICATION_PREDELETE and _thread != null:
|
|
||||||
_thread.wait_to_finish()
|
|
||||||
|
|
||||||
func _update_selector(array: Array):
|
|
||||||
var name := "text_editor/external/editor"
|
|
||||||
var settings := EditorInterface.get_editor_settings()
|
|
||||||
|
|
||||||
if !(settings.has_setting(name)):
|
|
||||||
settings.set(name, 0)
|
|
||||||
|
|
||||||
var installations: Array = ["Custom"]
|
|
||||||
for element in array:
|
|
||||||
var display_name: String = element.get("display", "")
|
|
||||||
# Replace special characters that break PROPERTY_HINT_ENUM format
|
|
||||||
# Comma is the enum delimiter, colon is used for explicit value assignment
|
|
||||||
display_name = display_name.replace(",", " •").replace(":", " -")
|
|
||||||
installations.append(display_name)
|
|
||||||
var options :String = ",".join(installations)
|
|
||||||
|
|
||||||
settings.add_property_info({
|
|
||||||
"name": name,
|
|
||||||
"type":TYPE_INT,
|
|
||||||
"hint":PROPERTY_HINT_ENUM,
|
|
||||||
"hint_string": options
|
|
||||||
})
|
|
||||||
|
|
||||||
# Connect to settings changes to update external editor path when selection changes
|
|
||||||
if not settings.settings_changed.is_connected(_on_selection_changed):
|
|
||||||
settings.settings_changed.connect(_on_selection_changed.bind())
|
|
||||||
|
|
||||||
func _on_selection_changed() -> void:
|
|
||||||
var name := "text_editor/external/editor"
|
|
||||||
var settings := EditorInterface.get_editor_settings()
|
|
||||||
var selected_index: int = settings.get_setting(name)
|
|
||||||
|
|
||||||
# Index 0 is "Custom", so user manages the path manually
|
|
||||||
if selected_index == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Map to actual installation (offset by 1 because of "Custom" at index 0)
|
|
||||||
var installation_index := selected_index - 1
|
|
||||||
var installations_array = _installations_found
|
|
||||||
if installation_index >= 0 and installation_index < installations_array.size():
|
|
||||||
var installation = installations_array[installation_index]
|
|
||||||
var new_path: String = installation.get("path", "")
|
|
||||||
if not new_path.is_empty():
|
|
||||||
EditorSettingsService.new().set_external_editor_path(settings, new_path)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cfsu1rbg0ypem
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends RefCounted
|
|
||||||
class_name PresetApplier
|
|
||||||
|
|
||||||
var presets_path: String
|
|
||||||
|
|
||||||
func _init(p_path: String) -> void:
|
|
||||||
presets_path = p_path
|
|
||||||
|
|
||||||
func get_preset_key(is_active: bool) -> String:
|
|
||||||
return "on" if is_active else "off"
|
|
||||||
|
|
||||||
func apply_preset(editor_settings: EditorSettings, is_active: bool) -> void:
|
|
||||||
var data: Dictionary = JsonUtils.load_dict_from_file(presets_path)
|
|
||||||
if data.is_empty():
|
|
||||||
push_warning("Failed to load presets: %s" % presets_path)
|
|
||||||
return
|
|
||||||
|
|
||||||
var new_preset_key := get_preset_key(is_active)
|
|
||||||
var previous_preset_key := get_preset_key(not is_active)
|
|
||||||
|
|
||||||
if not data.has(new_preset_key):
|
|
||||||
push_warning("Preset '%s' not found in presets.json" % new_preset_key)
|
|
||||||
return
|
|
||||||
|
|
||||||
var new_preset := data[new_preset_key] as Dictionary
|
|
||||||
# Reset keys from previous preset that are missing in the new preset
|
|
||||||
if data.has(previous_preset_key):
|
|
||||||
var previous_preset := data[previous_preset_key] as Dictionary
|
|
||||||
for key in previous_preset:
|
|
||||||
if not new_preset.has(key):
|
|
||||||
editor_settings.set_setting(str(key), editor_settings.property_get_revert(str(key)))
|
|
||||||
# Apply the new preset
|
|
||||||
for key in new_preset:
|
|
||||||
editor_settings.set_setting(str(key), new_preset[key])
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bjiaycxso8h8u
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends RefCounted
|
|
||||||
class_name EditorSettingsService
|
|
||||||
|
|
||||||
func set_external_editor_path(editor_settings: EditorSettings, path: String) -> void:
|
|
||||||
editor_settings.set_setting("text_editor/external/exec_path", path)
|
|
||||||
|
|
||||||
func has_valid_external_editor_path(editor_settings: EditorSettings) -> bool:
|
|
||||||
var has_setting: bool = editor_settings.has_setting("text_editor/external/exec_path")
|
|
||||||
if not has_setting:
|
|
||||||
return false
|
|
||||||
var path : String = editor_settings.get_setting("text_editor/external/exec_path")
|
|
||||||
var exists := not path.is_empty() && (FileAccess.file_exists(path) || DirAccess.dir_exists_absolute(path))
|
|
||||||
return exists
|
|
||||||
|
|
||||||
func set_use_external_editor(editor_settings: EditorSettings, enabled: bool) -> void:
|
|
||||||
editor_settings.set_setting("text_editor/external/use_external_editor", enabled)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bdiu78ot0rrkc
|
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,151 +0,0 @@
|
|||||||
"""
|
|
||||||
Blender 5.1 script: create walk + idle animations for matador.
|
|
||||||
Run: blender --background matador.blend --python create_matador_anims.py
|
|
||||||
"""
|
|
||||||
import bpy, math, os
|
|
||||||
|
|
||||||
|
|
||||||
# ── setup ──────────────────────────────────────────────────────────────────────
|
|
||||||
arm_obj = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
|
|
||||||
bpy.context.view_layer.objects.active = arm_obj
|
|
||||||
bpy.ops.object.mode_set(mode='POSE')
|
|
||||||
|
|
||||||
if arm_obj.animation_data is None:
|
|
||||||
arm_obj.animation_data_create()
|
|
||||||
|
|
||||||
pb = arm_obj.pose.bones
|
|
||||||
|
|
||||||
# Ensure all animated bones use XYZ Euler
|
|
||||||
for bname in ['leg_L', 'leg_R', 'shin_L', 'shin_R',
|
|
||||||
'arm_L', 'arm_R', 'forearm_L', 'forearm_R',
|
|
||||||
'collarbone_L', 'collarbone_R']:
|
|
||||||
pb[bname].rotation_mode = 'XYZ'
|
|
||||||
|
|
||||||
CYCLE = 30 # frames (1 s at 30 fps)
|
|
||||||
LEG_SWING = 25 # degrees
|
|
||||||
KNEE_BEND = 30
|
|
||||||
ARM_SWING = 15
|
|
||||||
ARM_DOWN = 55 # lower arms from T-pose around bone-local Y
|
|
||||||
ELBOW_BEND = 25
|
|
||||||
|
|
||||||
|
|
||||||
# ── WALK action ────────────────────────────────────────────────────────────────
|
|
||||||
# Remove old actions if they exist
|
|
||||||
for old_name in ['walk', 'idle']:
|
|
||||||
if old_name in bpy.data.actions:
|
|
||||||
bpy.data.actions.remove(bpy.data.actions[old_name])
|
|
||||||
|
|
||||||
walk = bpy.data.actions.new(name='walk')
|
|
||||||
walk.use_fake_user = True
|
|
||||||
walk.use_cyclic = True
|
|
||||||
arm_obj.animation_data.action = walk
|
|
||||||
|
|
||||||
# Clear all pose bone transforms first
|
|
||||||
bpy.ops.pose.select_all(action='SELECT')
|
|
||||||
bpy.ops.pose.rot_clear()
|
|
||||||
|
|
||||||
arm_down_l = math.radians(ARM_DOWN)
|
|
||||||
arm_down_r = math.radians(-ARM_DOWN)
|
|
||||||
|
|
||||||
for f in range(0, CYCLE + 1, 2):
|
|
||||||
t = (f / CYCLE) * math.tau
|
|
||||||
|
|
||||||
# Legs – swing forward/back on bone-local X
|
|
||||||
pb['leg_L'].rotation_euler = (math.sin(t) * math.radians(LEG_SWING), 0, 0)
|
|
||||||
pb['leg_L'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
|
|
||||||
pb['leg_R'].rotation_euler = (math.sin(t + math.pi) * math.radians(LEG_SWING), 0, 0)
|
|
||||||
pb['leg_R'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
|
|
||||||
# Shins – only bend backward when leg is back
|
|
||||||
val_l = max(0.0, -math.sin(t)) * math.radians(KNEE_BEND)
|
|
||||||
val_r = max(0.0, -math.sin(t + math.pi)) * math.radians(KNEE_BEND)
|
|
||||||
pb['shin_L'].rotation_euler = (val_l, 0, 0)
|
|
||||||
pb['shin_L'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
pb['shin_R'].rotation_euler = (val_r, 0, 0)
|
|
||||||
pb['shin_R'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
|
|
||||||
# Arms – lower from T-pose (Y) + swing (X, opposite phase to legs)
|
|
||||||
swing_l = math.sin(t + math.pi) * math.radians(ARM_SWING)
|
|
||||||
swing_r = math.sin(t) * math.radians(ARM_SWING)
|
|
||||||
pb['arm_L'].rotation_euler = (swing_l, arm_down_l, 0)
|
|
||||||
pb['arm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
pb['arm_R'].rotation_euler = (swing_r, arm_down_r, 0)
|
|
||||||
pb['arm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
|
|
||||||
# Forearms – constant slight bend
|
|
||||||
pb['forearm_L'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
|
|
||||||
pb['forearm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
pb['forearm_R'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
|
|
||||||
pb['forearm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
|
|
||||||
# Collarbones – identity
|
|
||||||
pb['collarbone_L'].rotation_euler = (0, 0, 0)
|
|
||||||
pb['collarbone_L'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
pb['collarbone_R'].rotation_euler = (0, 0, 0)
|
|
||||||
pb['collarbone_R'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
|
|
||||||
print("Created 'walk' action")
|
|
||||||
|
|
||||||
|
|
||||||
# ── IDLE action ────────────────────────────────────────────────────────────────
|
|
||||||
idle = bpy.data.actions.new(name='idle')
|
|
||||||
idle.use_fake_user = True
|
|
||||||
arm_obj.animation_data.action = idle
|
|
||||||
|
|
||||||
bpy.ops.pose.select_all(action='SELECT')
|
|
||||||
bpy.ops.pose.rot_clear()
|
|
||||||
|
|
||||||
# Arms lowered, everything else at rest – 2 frames for a valid action
|
|
||||||
for f in [0, 1]:
|
|
||||||
pb['arm_L'].rotation_euler = (0, arm_down_l, 0)
|
|
||||||
pb['arm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
pb['arm_R'].rotation_euler = (0, arm_down_r, 0)
|
|
||||||
pb['arm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
pb['forearm_L'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
|
|
||||||
pb['forearm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
pb['forearm_R'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
|
|
||||||
pb['forearm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
for bname in ['collarbone_L', 'collarbone_R', 'leg_L', 'leg_R', 'shin_L', 'shin_R']:
|
|
||||||
pb[bname].rotation_euler = (0, 0, 0)
|
|
||||||
pb[bname].keyframe_insert(data_path='rotation_euler', frame=f)
|
|
||||||
|
|
||||||
print("Created 'idle' action")
|
|
||||||
|
|
||||||
|
|
||||||
# ── NLA tracks for export ─────────────────────────────────────────────────────
|
|
||||||
bpy.ops.object.mode_set(mode='OBJECT')
|
|
||||||
|
|
||||||
# Clear existing NLA tracks
|
|
||||||
if arm_obj.animation_data.nla_tracks:
|
|
||||||
for t in list(arm_obj.animation_data.nla_tracks):
|
|
||||||
arm_obj.animation_data.nla_tracks.remove(t)
|
|
||||||
|
|
||||||
tw = arm_obj.animation_data.nla_tracks.new()
|
|
||||||
tw.name = 'walk'
|
|
||||||
sw = tw.strips.new('walk', 0, walk)
|
|
||||||
|
|
||||||
ti = arm_obj.animation_data.nla_tracks.new()
|
|
||||||
ti.name = 'idle'
|
|
||||||
si = ti.strips.new('idle', 0, idle)
|
|
||||||
|
|
||||||
# ── export FBX ─────────────────────────────────────────────────────────────────
|
|
||||||
fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador.fbx')
|
|
||||||
bpy.ops.export_scene.fbx(
|
|
||||||
filepath=fbx_path,
|
|
||||||
use_selection=False,
|
|
||||||
bake_anim=True,
|
|
||||||
bake_anim_use_all_actions=True,
|
|
||||||
bake_anim_use_nla_strips=False,
|
|
||||||
bake_anim_force_startend_keying=True,
|
|
||||||
bake_anim_step=1.0,
|
|
||||||
bake_anim_simplify_factor=0.0,
|
|
||||||
add_leaf_bones=False,
|
|
||||||
primary_bone_axis='Y',
|
|
||||||
secondary_bone_axis='X',
|
|
||||||
apply_scale_options='FBX_SCALE_ALL',
|
|
||||||
)
|
|
||||||
print('Exported FBX to:', fbx_path)
|
|
||||||
|
|
||||||
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
|
|
||||||
print('Saved .blend')
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,136 +0,0 @@
|
|||||||
"""
|
|
||||||
Blender 5.1 script: render preview frames of matador animations to PNG.
|
|
||||||
Run: blender --background matador.blend --python render_anim_preview.py
|
|
||||||
|
|
||||||
Outputs to art/blender/preview/ directory:
|
|
||||||
walk_frame_00.png .. walk_frame_09.png
|
|
||||||
idle_frame_00.png
|
|
||||||
|
|
||||||
Use this to visually verify bone orientations BEFORE exporting to Godot.
|
|
||||||
"""
|
|
||||||
import bpy
|
|
||||||
import os
|
|
||||||
import math
|
|
||||||
|
|
||||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
|
||||||
PREVIEW_DIR = os.path.join(os.path.dirname(bpy.data.filepath), "preview")
|
|
||||||
RENDER_RES_X = 512
|
|
||||||
RENDER_RES_Y = 512
|
|
||||||
WALK_FRAMES = 10 # number of evenly-spaced frames to render from walk cycle
|
|
||||||
CAMERA_DISTANCE = 5.0
|
|
||||||
CAMERA_HEIGHT = 1.0
|
|
||||||
CAMERA_ANGLES = [0, 90] # degrees — front and side views
|
|
||||||
|
|
||||||
os.makedirs(PREVIEW_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
# ── Find armature ─────────────────────────────────────────────────────────────
|
|
||||||
arm_obj = next((o for o in bpy.data.objects if o.type == 'ARMATURE'), None)
|
|
||||||
if arm_obj is None:
|
|
||||||
raise RuntimeError("No armature found in scene")
|
|
||||||
|
|
||||||
# ── Setup render settings ─────────────────────────────────────────────────────
|
|
||||||
scene = bpy.context.scene
|
|
||||||
scene.render.resolution_x = RENDER_RES_X
|
|
||||||
scene.render.resolution_y = RENDER_RES_Y
|
|
||||||
scene.render.resolution_percentage = 100
|
|
||||||
scene.render.film_transparent = True
|
|
||||||
scene.render.image_settings.file_format = 'PNG'
|
|
||||||
scene.render.image_settings.color_mode = 'RGBA'
|
|
||||||
scene.render.engine = 'BLENDER_EEVEE'
|
|
||||||
|
|
||||||
# ── Create or reuse camera ────────────────────────────────────────────────────
|
|
||||||
cam_data = bpy.data.cameras.get("PreviewCam") or bpy.data.cameras.new("PreviewCam")
|
|
||||||
cam_data.lens = 50
|
|
||||||
cam_obj = bpy.data.objects.get("PreviewCamObj")
|
|
||||||
if cam_obj is None:
|
|
||||||
cam_obj = bpy.data.objects.new("PreviewCamObj", cam_data)
|
|
||||||
scene.collection.objects.link(cam_obj)
|
|
||||||
scene.camera = cam_obj
|
|
||||||
|
|
||||||
# ── Create or reuse key light ─────────────────────────────────────────────────
|
|
||||||
light_data = bpy.data.lights.get("PreviewLight") or bpy.data.lights.new("PreviewLight", type='SUN')
|
|
||||||
light_data.energy = 3.0
|
|
||||||
light_obj = bpy.data.objects.get("PreviewLightObj")
|
|
||||||
if light_obj is None:
|
|
||||||
light_obj = bpy.data.objects.new("PreviewLightObj", light_data)
|
|
||||||
scene.collection.objects.link(light_obj)
|
|
||||||
light_obj.rotation_euler = (math.radians(45), math.radians(30), 0)
|
|
||||||
|
|
||||||
# ── Create or reuse fill light ────────────────────────────────────────────────
|
|
||||||
fill_data = bpy.data.lights.get("PreviewFill") or bpy.data.lights.new("PreviewFill", type='SUN')
|
|
||||||
fill_data.energy = 1.0
|
|
||||||
fill_obj = bpy.data.objects.get("PreviewFillObj")
|
|
||||||
if fill_obj is None:
|
|
||||||
fill_obj = bpy.data.objects.new("PreviewFillObj", fill_data)
|
|
||||||
scene.collection.objects.link(fill_obj)
|
|
||||||
fill_obj.rotation_euler = (math.radians(60), math.radians(-45), 0)
|
|
||||||
|
|
||||||
# ── Ensure armature is visible ────────────────────────────────────────────────
|
|
||||||
arm_obj.hide_render = False
|
|
||||||
arm_obj.hide_viewport = False
|
|
||||||
for child in arm_obj.children:
|
|
||||||
child.hide_render = False
|
|
||||||
child.hide_viewport = False
|
|
||||||
|
|
||||||
|
|
||||||
def position_camera(angle_deg: float) -> None:
|
|
||||||
"""Place camera at given horizontal angle around the armature center."""
|
|
||||||
rad = math.radians(angle_deg)
|
|
||||||
cam_obj.location = (
|
|
||||||
math.sin(rad) * CAMERA_DISTANCE,
|
|
||||||
CAMERA_HEIGHT,
|
|
||||||
math.cos(rad) * CAMERA_DISTANCE,
|
|
||||||
)
|
|
||||||
# Point camera at armature center (roughly hip height)
|
|
||||||
target = arm_obj.location.copy()
|
|
||||||
target.y = 0.9
|
|
||||||
direction = target - cam_obj.location
|
|
||||||
rot_quat = direction.to_track_quat('-Z', 'Y')
|
|
||||||
cam_obj.rotation_euler = rot_quat.to_euler()
|
|
||||||
|
|
||||||
|
|
||||||
def render_action(action_name: str, frame_count: int) -> None:
|
|
||||||
"""Render frame_count evenly-spaced frames of the named action."""
|
|
||||||
action = bpy.data.actions.get(action_name)
|
|
||||||
if action is None:
|
|
||||||
print(f"WARNING: Action '{action_name}' not found, skipping")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Assign action
|
|
||||||
if arm_obj.animation_data is None:
|
|
||||||
arm_obj.animation_data_create()
|
|
||||||
arm_obj.animation_data.action = action
|
|
||||||
|
|
||||||
frame_start = int(action.frame_range[0])
|
|
||||||
frame_end = int(action.frame_range[1])
|
|
||||||
total_frames = max(frame_end - frame_start, 1)
|
|
||||||
|
|
||||||
for angle_deg in CAMERA_ANGLES:
|
|
||||||
position_camera(angle_deg)
|
|
||||||
angle_label = f"{angle_deg:03d}deg"
|
|
||||||
|
|
||||||
for i in range(frame_count):
|
|
||||||
t = i / max(frame_count - 1, 1) if frame_count > 1 else 0
|
|
||||||
frame = frame_start + int(t * total_frames)
|
|
||||||
scene.frame_set(frame)
|
|
||||||
|
|
||||||
filename = f"{action_name}_{angle_label}_frame_{i:02d}.png"
|
|
||||||
filepath = os.path.join(PREVIEW_DIR, filename)
|
|
||||||
scene.render.filepath = filepath
|
|
||||||
bpy.ops.render.render(write_still=True)
|
|
||||||
print(f" Rendered: {filename}")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Render all animations ─────────────────────────────────────────────────────
|
|
||||||
print("=" * 60)
|
|
||||||
print("RENDERING ANIMATION PREVIEWS")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
render_action("walk", WALK_FRAMES)
|
|
||||||
render_action("idle", 1)
|
|
||||||
|
|
||||||
# ── Cleanup preview objects (optional — keep them for re-runs) ────────────────
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"Preview images saved to: {PREVIEW_DIR}")
|
|
||||||
print(f"Total actions rendered: {len([a for a in ['walk', 'idle'] if bpy.data.actions.get(a)])}")
|
|
||||||
print("=" * 60)
|
|
||||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user