Reorganize the codebase into role-based folders (snake_case)

Move the 40-odd root-level scripts and scenes into a tree that groups by role,
per the approved roadmap (WS2/WS3): actors/{player,matador,bear}, systems/{combat,
fx,input,debug,render,crowd,config}, ui/, props/ (was tscn_s/). Rename the shell
scene.tscn -> levels/game_shell.tscn and every PascalCase scene to snake_case
(Player.tscn -> player.tscn, MainMenu.tscn -> main_menu.tscn, etc.) so the
case-insensitive web/macOS filesystems can't alias two files together.

Done as a scripted move: each file travels with its .uid; every quoted res:// path
across scenes, scripts, .tres, and project.godot's 9 autoloads is rewritten; the
.godot uid/class cache is rebuilt from scratch. Scenes still resolve by uid, so the
rewrite is belt-and-suspenders. Lint globs (run_tests.sh + both CI workflows) now
walk the new dirs; an orphan animation_tree.gd.uid and 7 pre-existing trailing-
whitespace lints (barrel/torch, never covered by the old root-only glob) are cleaned.

Full suite green on both sides. Asset consolidation (Assets/Animations/fonts ->
art/) is deferred — moving imported .glb/.png/.ttf is safer done in the editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
This commit is contained in:
2026-09-06 00:04:41 +03:00
parent b839252fe0
commit 7b6a141767
141 changed files with 199 additions and 158 deletions
+80
View File
@@ -0,0 +1,80 @@
class_name DamageEvent
extends RefCounted
## The one typed payload for every hit in the game — a bull goring a matador, a bear slam popping
## the bull, a thrown spear landing. It replaces the scattered, stringly Object.call(&"take_sword_hit",
## "gored") paths: an emitter (a Hitbox) fills one of these and hands it to the target's
## receive_damage(event). One shape to read, log and type-check, instead of four call conventions.
##
## It serves both victim models the game already has: the bull, which dies on one clean hit
## (lethal + cause + hit_position), and an enemy, which is launched/ragdolled by an impulse
## (knockback_dir · knockback_strength + knock_up). A given hit just leaves the fields it doesn't
## use at their defaults.
enum Cause { GENERIC, GORED, MAULED, THROWN, SLAMMED, KICKED, DASHED }
## Who dealt the hit — for attribution and for ignoring self-overlap. May be null.
var source: Node = null
## What kind of hit; drives the victim's reaction (blood/vignette/FX) and any future resistances.
var cause: Cause = Cause.GENERIC
## World contact point, so impact FX spawn where the hit actually landed.
var hit_position: Vector3 = Vector3.ZERO
## Planar push direction (need not be normalised). Vector3.ZERO = no horizontal knockback.
var knockback_dir: Vector3 = Vector3.ZERO
## Impulse magnitude behind knockback_dir — the enemy launch/ragdoll strength.
var knockback_strength: float = 0.0
## Upward launch in m/s (an overhead smash pop, a leap-slam outer ring). 0 = a grounded hit.
var knock_up: float = 0.0
## One clean hit ends the run for the bull; most enemy-dealt hits are lethal to it. Enemies that
## are merely launched (not killed) by the bull's abilities set this false.
var lethal: bool = true
static func make(kind: Cause, at: Vector3, src: Node = null) -> DamageEvent:
var e := DamageEvent.new()
e.cause = kind
e.hit_position = at
e.source = src
return e
## The single entry point every attacker uses to wound a target: build the typed event and hand it
## to receive_damage(). Falls back to the legacy string call for any target not yet migrated, so a
## caller never has to branch on which contract the victim speaks. `knock_up_v` is the already-shaped
## pop (0 = grounded); callers that scale it by distance compute it before calling.
static func deal(target: Node, kind: Cause, at: Vector3, src: Node = null, knock_up_v: float = 0.0) -> void:
if target == null:
return
if target.has_method(&"receive_damage"):
var event := make(kind, at, src)
event.knock_up = knock_up_v
target.call(&"receive_damage", event)
return
if target.has_method(&"take_sword_hit"):
target.call(&"take_sword_hit", make(kind, at, src).cause_name(), at)
if knock_up_v > 0.0 and target.has_method(&"apply_knock_up"):
target.call(&"apply_knock_up", knock_up_v)
## Lower-case name of the cause, for logs and for the bull's existing cause-keyed reactions
## ("gored" / "mauled" / "thrown") during the migration off string causes.
func cause_name() -> String:
return String(Cause.keys()[cause]).to_lower()
## Maps a legacy cause string onto the enum, so a Hitbox's exported damage_cause round-trips
## exactly (gored/mauled/thrown are the only ones in use). Unknown strings fall back to GENERIC.
static func cause_from(text: String) -> Cause:
match text.to_lower():
"gored":
return Cause.GORED
"mauled":
return Cause.MAULED
"thrown":
return Cause.THROWN
"slammed":
return Cause.SLAMMED
"kicked":
return Cause.KICKED
"dashed":
return Cause.DASHED
return Cause.GENERIC
+1
View File
@@ -0,0 +1 @@
uid://cwnr3ggsrii76
+98
View File
@@ -0,0 +1,98 @@
@tool
extends Area3D
class_name Hitbox
## A designer-placeable attack hitbox: drop this into a scene, give it a CollisionShape3D
## child, and drag the shape to size/position the attack's reach in the editor. The shape
## itself decides what got hit — no distance/facing math in code.
##
## Inactive by default; the owning script calls activate() for the swing's active window and
## deactivate() when it ends. Each activation only hits a given body once (the bull's own
## i-frames also collapse a flurry, but this keeps a single swing from re-triggering).
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
## How the hit is tagged on the bull's take_sword_hit ("mauled" / "gored" / …).
@export var damage_cause: String = "mauled"
## Upward launch (m/s) applied to the bull on hit — e.g. an overhead smash that pops it up.
@export var knock_up: float = 0.0
## Knock-up distance shaping, measured planar (XZ) from this hitbox's own origin to the body:
## below `knock_up_min_dist` no pop is applied (leap slam: only the outer ring launches). If
## `knock_up_max_dist` > min, the pop ramps 0→`knock_up` from min to max (overhead smash: the
## further out the hit lands, the higher the bull flies); at/again 0 it's full past the min.
@export var knock_up_min_dist: float = 0.0
@export var knock_up_max_dist: float = 0.0
var _active: bool = false
var _hit_this_swing: Dictionary = {}
# Whether the most recent active window connected. Cleared on activate(), NOT deactivate(), so the
# owner can read it after the window closes (e.g. a landed stab breaks off to taunt).
var _did_hit: bool = false
func _ready() -> void:
# Detect the bull's body (layer BULL) only; never collide or push physics.
collision_layer = 0
collision_mask = PhysicsLayers.BULL
monitorable = false
monitoring = false
if Engine.is_editor_hint():
return
body_entered.connect(_on_body_entered)
func activate() -> void:
if _active:
return
_active = true
_hit_this_swing.clear()
_did_hit = false
monitoring = true
func deactivate() -> void:
_active = false
monitoring = false
# Did the window that just closed connect? Valid until the next activate() clears it.
func did_hit() -> bool:
return _did_hit
func _on_body_entered(body: Node3D) -> void:
_apply_hit(body)
# For AoE slams the bull is usually already standing inside the shape when it activates, so
# body_entered never fires. The owner calls sweep() each active frame to catch current overlaps
# (still deduped per activation, so the bull is only hit once per slam).
func sweep() -> void:
if not _active:
return
for body in get_overlapping_bodies():
_apply_hit(body)
func _apply_hit(body: Node3D) -> void:
if not _active or not body.is_in_group(&"player"):
return
if _hit_this_swing.has(body):
return
_hit_this_swing[body] = true
_did_hit = true
var ku := _knock_up_for(body) if knock_up > 0.0 else 0.0
DamageEvent.deal(body, DamageEvent.cause_from(damage_cause), body.global_position, self, ku)
# Scale the pop by how far the body is from this hitbox's origin (planar). See the export docs.
func _knock_up_for(body: Node3D) -> float:
if knock_up_min_dist <= 0.0 and knock_up_max_dist <= 0.0:
return knock_up
var to := body.global_position - global_position
var d := Vector2(to.x, to.z).length()
if d < knock_up_min_dist:
return 0.0
if knock_up_max_dist > knock_up_min_dist:
var t := clampf((d - knock_up_min_dist) / (knock_up_max_dist - knock_up_min_dist), 0.0, 1.0)
return knock_up * t
return knock_up
+1
View File
@@ -0,0 +1 @@
uid://dlwjiw42q6b5f
+11
View File
@@ -0,0 +1,11 @@
## Central registry of the 3D physics collision-layer bits. These mirror the
## layer_names in project.godot so the editor's layer picker reads the same words.
##
## The bull only collides with WORLD, so a killed matador is lifted off WORLD onto
## CORPSE alone (see matador_ragdoll.gd). The ground and walls carry WORLD | CORPSE
## (tagged at level load in game_shell.gd), so the ragdoll still rests on the arena
## while the bull passes through the bodies instead of tripping over them.
const WORLD: int = 1 # layer 1 — environment + live actors (bull, matadors, spears)
const BULL: int = 2 # layer 2 — the bull's own body
const CORPSE: int = 4 # layer 3 — ragdolled matadors; ground/walls also carry this
+1
View File
@@ -0,0 +1 @@
uid://d01o2ul83waw5
+117
View File
@@ -0,0 +1,117 @@
extends Node
## Player-facing game options (autoload `Settings`), persisted to user://settings.cfg.
## Distinct from DP (debug tunables) and Controls (keybinds): this is the small set of
## comfort/accessibility toggles the options menu exposes to the player.
signal changed(key: String, value: Variant)
const SAVE_PATH := "user://settings.cfg"
const SECTION := "options"
## Master screen-shake toggle — gates every camera_iso.trigger_hit (hits and ability FX).
var screen_shake: bool = true
## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed).
var gore: bool = true
## Audio volumes stored as linear 0–1. Applied to AudioServer buses on load/change.
var volume_master: float = 1.0
var volume_ambient: float = 1.0
var volume_effects: float = 1.0
var mute_all: bool = false
func _ready() -> void:
_ensure_buses()
load_saved()
## Ensure Ambient and Effects buses exist as children of Master.
func _ensure_buses() -> void:
for bus_name: String in ["Ambient", "Effects"]:
if AudioServer.get_bus_index(bus_name) == -1:
AudioServer.add_bus()
var idx := AudioServer.bus_count - 1
AudioServer.set_bus_name(idx, bus_name)
AudioServer.set_bus_send(idx, &"Master")
func _apply_audio() -> void:
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master))
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Ambient"), _vol_db(volume_ambient))
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Effects"), _vol_db(volume_effects))
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), mute_all)
func _vol_db(linear: float) -> float:
return linear_to_db(maxf(linear, 0.0001))
func set_screen_shake(value: bool) -> void:
if screen_shake == value:
return
screen_shake = value
changed.emit("screen_shake", value)
save()
func set_gore(value: bool) -> void:
if gore == value:
return
gore = value
changed.emit("gore", value)
save()
func set_volume_master(value: float) -> void:
volume_master = clampf(value, 0.0, 1.0)
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master))
changed.emit("volume_master", volume_master)
save()
func set_volume_ambient(value: float) -> void:
volume_ambient = clampf(value, 0.0, 1.0)
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Ambient"), _vol_db(volume_ambient))
changed.emit("volume_ambient", volume_ambient)
save()
func set_volume_effects(value: float) -> void:
volume_effects = clampf(value, 0.0, 1.0)
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Effects"), _vol_db(volume_effects))
changed.emit("volume_effects", volume_effects)
save()
func set_mute_all(value: bool) -> void:
if mute_all == value:
return
mute_all = value
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), mute_all)
changed.emit("mute_all", value)
save()
func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value(SECTION, "screen_shake", screen_shake)
cfg.set_value(SECTION, "gore", gore)
cfg.set_value(SECTION, "volume_master", volume_master)
cfg.set_value(SECTION, "volume_ambient", volume_ambient)
cfg.set_value(SECTION, "volume_effects", volume_effects)
cfg.set_value(SECTION, "mute_all", mute_all)
cfg.save(SAVE_PATH)
func load_saved() -> void:
var cfg := ConfigFile.new()
if cfg.load(SAVE_PATH) != OK:
_apply_audio()
return
screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool
gore = cfg.get_value(SECTION, "gore", true) as bool
volume_master = cfg.get_value(SECTION, "volume_master", 1.0) as float
volume_ambient = cfg.get_value(SECTION, "volume_ambient", 1.0) as float
volume_effects = cfg.get_value(SECTION, "volume_effects", 1.0) as float
mute_all = cfg.get_value(SECTION, "mute_all", false) as bool
_apply_audio()
+1
View File
@@ -0,0 +1 @@
uid://dyspgr7m238la
+6
View File
@@ -0,0 +1,6 @@
extends AnimationPlayer
func _ready() -> void:
speed_scale = randf_range(0.5, 1.5)
play("ArmatureAction")
+1
View File
@@ -0,0 +1 @@
uid://b6gdjsr6fcwc2
+35
View File
@@ -0,0 +1,35 @@
shader_type spatial;
// Flat crowd sprite. Each instance is oriented at build time to face the arena centre
// (see crowd_billboards.gd) — NOT the camera — so spectators on different segments of
// the ring face inward toward the action, like a real stand. Unshaded (the sheet was
// baked with lighting), alpha-tested (no transparency sorting across hundreds of
// quads), nearest-filtered for a crisp PS1 look that matches the ps1_filter effect.
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_opaque;
uniform sampler2D sheet : source_color, filter_nearest;
uniform int hframes = 8;
uniform float fps = 10.0;
uniform float alpha_clip = 0.4;
varying flat float v_frame;
varying flat vec3 v_tint;
void vertex() {
// Per-instance clap phase (0..1) and tint are packed into the MultiMesh custom data.
// The instance transform already carries the inward facing, so no billboard maths.
float phase = INSTANCE_CUSTOM.x;
v_tint = INSTANCE_CUSTOM.yzw;
v_frame = floor(mod(TIME * fps + phase * float(hframes), float(hframes)));
}
void fragment() {
// Sheet is two rows: top = front, bottom = back. Show the back when the camera is
// behind the spectator (they're facing into the arena, away from us).
float u = (UV.x + v_frame) / float(hframes);
float v = UV.y * 0.5 + (FRONT_FACING ? 0.0 : 0.5);
vec4 c = texture(sheet, vec2(u, v));
if (c.a < alpha_clip) {
discard;
}
ALBEDO = c.rgb * v_tint;
}
@@ -0,0 +1 @@
uid://hkdivctavovb
+119
View File
@@ -0,0 +1,119 @@
extends MultiMeshInstance3D
## A whole stand of cheering spectators rendered as flat billboards in a single draw
## call — the performant replacement for dozens of skinned crowd skeletons. The sprite
## sheet is baked from the existing crowd figure by tools/bake_crowd_sheet.gd.
##
## Placement comes from CrowdMarker nodes the designer drops into the level (collected
## via the &"crowd_marker" group): each marker gives one spectator its world position and
## a chosen facing (one of six 60°-spaced headings). If no markers are present the scene
## falls back to a generated tribune so it still populates.
const SHEET_PATH := "res://Assets/crowd_clap_sheet.png"
const SHADER_PATH := "res://systems/crowd/crowd_billboard.gdshader"
## World height of a spectator billboard, in metres. Sheet aspect sets the width.
@export var figure_height: float = 2.0
@export var frames: int = 8
@export var clap_fps: float = 10.0
## Subtle per-spectator brightness spread so the crowd doesn't read as clones.
@export_range(0.0, 0.5) var tint_variation: float = 0.18
# Fallback tribune (used only when source_path resolves to nothing) — concentric arcs
# of seats ringing the arena.
@export_group("Fallback stand")
@export var fallback_count: int = 120
@export var fallback_inner_radius: float = 26.0
@export var fallback_rows: int = 6
@export var fallback_row_rise: float = 1.6
@export var fallback_row_step: float = 2.2
@export var fallback_seat_step: float = 2.4
var _rng := RandomNumberGenerator.new()
func _ready() -> void:
_rng.seed = hash("bullosseum-crowd") # deterministic layout/phases across runs
# Deferred so every CrowdMarker has run _ready and joined the group before we harvest.
_build.call_deferred()
func _build() -> void:
var sheet := load(SHEET_PATH) as Texture2D
if sheet == null:
push_warning("crowd_billboards: missing sheet %s — run tools/bake_crowd_sheet.gd" % SHEET_PATH)
return
# Sheet holds `frames` columns and 2 rows (front / back), so a cell is half-height.
var aspect := (float(sheet.get_width()) / float(frames)) / (float(sheet.get_height()) / 2.0)
var quad := QuadMesh.new()
quad.size = Vector2(figure_height * aspect, figure_height)
quad.center_offset = Vector3(0.0, figure_height * 0.5, 0.0) # pivot at the feet
var mat := ShaderMaterial.new()
mat.shader = load(SHADER_PATH) as Shader
mat.set_shader_parameter("sheet", sheet)
mat.set_shader_parameter("hframes", frames)
mat.set_shader_parameter("fps", clap_fps)
material_override = mat
# Each seat is a (global feet-position, unit forward) pair.
var positions := PackedVector3Array()
var facings: Array[Vector3] = []
_collect_seats(positions, facings)
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.use_custom_data = true
mm.mesh = quad
mm.instance_count = positions.size()
for i in positions.size():
var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety
mm.set_instance_transform(i, Transform3D(_upright_basis(facings[i], s), positions[i]))
var b := 1.0 + _rng.randf_range(-tint_variation, tint_variation)
mm.set_instance_custom_data(i, Color(_rng.randf(), b, b * 0.99, b * 0.98))
multimesh = mm
# Upright basis whose forward (+Z, the quad's face) points along `look`, uniformly
# scaled by `s`. World +Y stays up so spectators never tilt.
func _upright_basis(look: Vector3, s: float) -> Basis:
look.y = 0.0
look = look.normalized() if look.length() > 0.001 else Vector3(0.0, 0.0, 1.0)
var up := Vector3.UP
var basis := Basis()
basis.x = up.cross(look).normalized()
basis.y = up
basis.z = look
return basis.scaled(Vector3.ONE * s)
# Seats from designer-placed CrowdMarker nodes: each contributes its world position and
# its chosen 60°-spaced facing. With no markers present, fall back to a generated ring
# that faces inward so the scene still shows a crowd.
func _collect_seats(positions: PackedVector3Array, facings: Array[Vector3]) -> void:
for node in get_tree().get_nodes_in_group(&"crowd_marker"):
var marker := node as Node3D
if marker == null or not marker.has_method(&"facing_dir"):
continue
positions.append(marker.global_position)
facings.append(marker.facing_dir())
if positions.is_empty():
_fallback_stand(positions, facings)
func _fallback_stand(positions: PackedVector3Array, facings: Array[Vector3]) -> void:
var placed := 0
for row in fallback_rows:
var radius := fallback_inner_radius + float(row) * fallback_row_step
var y := float(row) * fallback_row_rise
var circumference := TAU * radius
var seats_in_row := int(circumference / fallback_seat_step)
for s in seats_in_row:
if placed >= fallback_count:
return
var ang := TAU * float(s) / float(seats_in_row)
var pos := Vector3(cos(ang) * radius, y, sin(ang) * radius)
positions.append(pos)
facings.append(-pos) # face the ring centre (origin)
placed += 1
+1
View File
@@ -0,0 +1 @@
uid://cj0ytlkuavmf4
+89
View File
@@ -0,0 +1,89 @@
@tool
extends Node3D
class_name CrowdMarker
## A designer-placed spot for one billboard spectator. Drop these into a level in the
## editor; crowd_billboards.gd harvests every marker (via the &"crowd_marker" group) into
## its MultiMesh at runtime, using each marker's global position and chosen facing.
##
## The marker draws a lightweight preview (a slab standing in for the sprite + a forward
## arrow) so its position and orientation are visible in the viewport. That preview is
## editor-only and never serialised into the scene, so it costs nothing at runtime.
## The six selectable facings, one every 60° about the Y axis. DEG_0 faces the quad's
## front (+Z) along world +Z; increasing values rotate clockwise looking down.
enum Orientation {
DEG_0,
DEG_60,
DEG_120,
DEG_180,
DEG_240,
DEG_300,
}
const ORIENTATION_STEPS := 6
## Metres — matches crowd_billboards.figure_height so the editor preview reads true to scale.
const PREVIEW_HEIGHT := 2.0
@export var orientation := Orientation.DEG_0:
set(value):
orientation = value
_apply_orientation()
var _preview: Node3D
func _ready() -> void:
add_to_group(&"crowd_marker")
if Engine.is_editor_hint():
_build_preview()
_apply_orientation()
# Facing about Y for the current orientation: 0, 60, 120 … degrees as radians.
func facing_yaw() -> float:
return float(orientation) * (TAU / float(ORIENTATION_STEPS))
# Unit forward the spectator's front (+Z) points along, on the horizontal plane.
func facing_dir() -> Vector3:
var a := facing_yaw()
return Vector3(sin(a), 0.0, cos(a))
func _apply_orientation() -> void:
if _preview != null:
_preview.rotation = Vector3(0.0, facing_yaw(), 0.0)
# Editor-only preview. Children are added without an owner so they are excluded from the
# saved .tscn (Godot only serialises nodes owned by the scene root).
func _build_preview() -> void:
_preview = Node3D.new()
add_child(_preview)
var aspect := 0.5
var slab := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(PREVIEW_HEIGHT * aspect, PREVIEW_HEIGHT)
quad.center_offset = Vector3(0.0, PREVIEW_HEIGHT * 0.5, 0.0) # stand on the marker
slab.mesh = quad
slab.material_override = _preview_material(Color(0.3, 0.7, 1.0, 0.35))
_preview.add_child(slab)
var arrow := MeshInstance3D.new()
var shaft := BoxMesh.new()
shaft.size = Vector3(0.08, 0.08, PREVIEW_HEIGHT * 0.5)
arrow.mesh = shaft
# Push the shaft out along +Z (the facing) and up to chest height.
arrow.position = Vector3(0.0, PREVIEW_HEIGHT * 0.5, PREVIEW_HEIGHT * 0.25)
arrow.material_override = _preview_material(Color(1.0, 0.85, 0.2, 0.9))
_preview.add_child(arrow)
func _preview_material(col: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = col
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
return mat
+1
View File
@@ -0,0 +1 @@
uid://70j3ks1emhkd
+12
View File
@@ -0,0 +1,12 @@
extends Node3D
func _ready() -> void:
# Mark the crowd so debug overlays (show_animation_name / show_bones) skip it —
# 72 ambient clapping spectators would otherwise flood the view with tags.
add_to_group(&"public")
for child in get_children():
var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer
if anim:
anim.speed_scale = randf_range(0.5, 1.5)
anim.play(&"ArmatureAction")
+1
View File
@@ -0,0 +1 @@
uid://bs682ey3p1ivj
+679
View File
@@ -0,0 +1,679 @@
extends Node
## Quake-style drop-down developer console (autoload "Console"). Toggle with the
## backtick/tilde key (`/~) — matched by physical position so it works on any
## keyboard layout and with or without Shift.
## Query a param: type its name. Set it: "name value".
## Tab = complete top match · ↑↓ = browse suggestions / history · Esc = close.
const CONSOLE_HEIGHT_RATIO := 0.25
const SLIDE_DURATION := 0.18
const MAX_SUGGESTIONS := 10
const LOG_MAX_LINES := 300
# Built-in commands (name → one-line help). Surfaced in autocomplete alongside
# param keys and printed by `help`. None collide with param key names.
const _COMMANDS := {
"save": "persist values to disk",
"reset": "reset all, or reset <name>",
"diff": "list params changed from default",
"toggle": "flip a bool param",
"list": "print params (optional section)",
"level": "select level and reload (level <id> — arena / arena_boss / bear)",
"restart": "reload the current level scene",
"clear": "clear the log",
"help": "show this help",
}
var _canvas: CanvasLayer
var _panel: PanelContainer
var _log: RichTextLabel
var _suggest_panel: PanelContainer
var _suggest_box: VBoxContainer
var _suggest_labels: Array[Label] = []
var _input_field: LineEdit
var _tween: Tween
var _is_open: bool = false
var _prev_mouse: Input.MouseMode = Input.MOUSE_MODE_CAPTURED
var _suggestions: Array[String] = []
var _selected_idx: int = -1
var _history: Array[String] = []
var _history_idx: int = -1 # -1 = editing the live line
var _pending_line: String = "" # live line stashed while browsing history
func _ready() -> void:
# Keep the console (and its slide tween/input) alive while the tree is paused.
process_mode = Node.PROCESS_MODE_ALWAYS
_build_ui()
func _build_ui() -> void:
var mono := SystemFont.new()
mono.font_names = PackedStringArray([
"JetBrains Mono", "DejaVu Sans Mono", "Consolas", "Menlo", "monospace",
])
_canvas = CanvasLayer.new()
_canvas.layer = 128
add_child(_canvas)
_panel = PanelContainer.new()
_panel.anchor_left = 0.0
_panel.anchor_right = 1.0
_panel.anchor_top = 0.0
_panel.anchor_bottom = 0.0
_panel.offset_bottom = 500.0
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.visible = false
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.04, 0.06, 0.04, 0.94)
bg.border_width_bottom = 2
bg.border_color = Color(0.2, 0.8, 0.2, 1.0)
_panel.add_theme_stylebox_override("panel", bg)
_canvas.add_child(_panel)
var root_vbox := VBoxContainer.new()
root_vbox.size_flags_horizontal = Control.SIZE_FILL
root_vbox.size_flags_vertical = Control.SIZE_FILL
_panel.add_child(root_vbox)
# ── Log ───────────────────────────────────────────────────────────────────
_log = RichTextLabel.new()
_log.bbcode_enabled = true
_log.scroll_following = true
_log.size_flags_vertical = Control.SIZE_EXPAND_FILL
_log.size_flags_horizontal = Control.SIZE_FILL
_log.add_theme_font_size_override("normal_font_size", 13)
_log.add_theme_constant_override("line_separation", 1)
# Monospace so the rpad-aligned suggestion/list columns actually line up.
_log.add_theme_font_override("normal_font", mono)
_log.add_theme_font_override("bold_font", mono)
_log.add_theme_font_override("mono_font", mono)
root_vbox.add_child(_log)
# ── Suggestion panel ──────────────────────────────────────────────────────
_suggest_panel = PanelContainer.new()
_suggest_panel.visible = false
_suggest_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sg_bg := StyleBoxFlat.new()
sg_bg.bg_color = Color(0.06, 0.10, 0.06, 0.97)
sg_bg.border_width_top = 1
sg_bg.border_color = Color(0.15, 0.55, 0.15, 0.8)
_suggest_panel.add_theme_stylebox_override("panel", sg_bg)
root_vbox.add_child(_suggest_panel)
_suggest_box = VBoxContainer.new()
_suggest_box.add_theme_constant_override("separation", 0)
_suggest_panel.add_child(_suggest_box)
for _i in MAX_SUGGESTIONS:
var lbl := Label.new()
lbl.visible = false
lbl.add_theme_font_size_override("font_size", 13)
lbl.add_theme_font_override("font", mono)
_suggest_box.add_child(lbl)
_suggest_labels.append(lbl)
root_vbox.add_child(HSeparator.new())
# ── Input row ─────────────────────────────────────────────────────────────
var in_margin := MarginContainer.new()
in_margin.add_theme_constant_override("margin_left", 10)
in_margin.add_theme_constant_override("margin_right", 10)
in_margin.add_theme_constant_override("margin_top", 4)
in_margin.add_theme_constant_override("margin_bottom", 6)
root_vbox.add_child(in_margin)
var in_row := HBoxContainer.new()
in_row.add_theme_constant_override("separation", 6)
in_margin.add_child(in_row)
var prompt_lbl := Label.new()
prompt_lbl.text = ">"
prompt_lbl.modulate = Color(0.35, 1.0, 0.35)
prompt_lbl.add_theme_font_override("font", mono)
in_row.add_child(prompt_lbl)
_input_field = LineEdit.new()
_input_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_input_field.placeholder_text = "param · param value · toggle <bool> · save reset diff list clear help"
_input_field.clear_button_enabled = true
_input_field.add_theme_font_size_override("font_size", 14)
_input_field.add_theme_font_override("font", mono)
# Drop the default LineEdit chrome (the white box border) — the panel is the frame.
var flat := StyleBoxFlat.new()
flat.bg_color = Color(0, 0, 0, 0)
flat.set_border_width_all(0)
flat.set_content_margin_all(2)
_input_field.add_theme_stylebox_override("normal", flat)
_input_field.add_theme_stylebox_override("focus", flat)
in_row.add_child(_input_field)
_input_field.text_changed.connect(_on_text_changed)
_input_field.text_submitted.connect(_on_submitted)
# ── Input handling ────────────────────────────────────────────────────────────
func _input(event: InputEvent) -> void:
if not (event is InputEventKey and (event as InputEventKey).pressed
and not (event as InputEventKey).echo):
return
var ke := event as InputEventKey
# Backtick/tilde — toggle regardless of focus. Match by physical position so a
# non-US layout or a Shift-produced ~ both open the console.
if ke.physical_keycode == KEY_QUOTELEFT:
_toggle()
get_viewport().set_input_as_handled()
return
if not _is_open:
return
# Ctrl+C clears the input line, Ctrl+L clears the log — familiar shell reflexes.
if ke.ctrl_pressed:
match ke.keycode:
KEY_C:
_clear_input()
get_viewport().set_input_as_handled()
return
KEY_L:
_log.clear()
get_viewport().set_input_as_handled()
return
match ke.keycode:
KEY_TAB:
_accept_completion()
get_viewport().set_input_as_handled()
KEY_UP:
_arrow(-1)
get_viewport().set_input_as_handled()
KEY_DOWN:
_arrow(1)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
_toggle()
get_viewport().set_input_as_handled()
# Focus can be stolen a frame or more after we re-grab it (submit, arrows, the tree
# unpausing), so a one-shot deferred grab loses the race. Enforce it every frame
# while the console is open — cheap, and it never lets the command line go dark.
func _process(_delta: float) -> void:
if _is_open and not _input_field.has_focus():
# Diagnostic: name whatever currently holds focus before we snatch it back, so
# a persistent thief (a HUD button, a menu control) shows up in the log instead
# of us guessing. Remove once the culprit is identified.
var owner: Control = get_viewport().gui_get_focus_owner()
var who: String = String(owner.name) if owner != null else "<nothing>"
_log_warn("focus stolen by %s — reclaiming" % who)
_input_field.grab_focus()
func _toggle() -> void:
_is_open = not _is_open
get_tree().paused = _is_open
var h := get_viewport().get_visible_rect().size.y * CONSOLE_HEIGHT_RATIO
_panel.offset_bottom = h
if _tween:
_tween.kill()
_tween = create_tween().set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUART)
if _is_open:
_prev_mouse = Input.mouse_mode
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_panel.position.y = -h
_panel.visible = true
_tween.tween_property(_panel, "position:y", 0.0, SLIDE_DURATION)
_tween.tween_callback(_input_field.grab_focus)
else:
_tween.tween_property(_panel, "position:y", -h, SLIDE_DURATION)
_tween.tween_callback(func() -> void: _panel.visible = false)
Input.mouse_mode = _prev_mouse
_clear_suggestions()
# ── Fuzzy autocomplete ────────────────────────────────────────────────────────
func _on_text_changed(text: String) -> void:
_selected_idx = -1
_history_idx = -1
var query := text.get_slice(" ", 0).strip_edges()
if query.is_empty():
_clear_suggestions()
return
_refresh_suggestions(query)
func _refresh_suggestions(query: String) -> void:
var ql := query.to_lower()
var scored : Array = []
# Commands rank above params on an equal score so a leading "re" surfaces the
# `reset` verb, not just params that happen to contain those letters.
for name: String in _COMMANDS:
var cs := _fuzzy_score(ql, name)
if cs > 0:
scored.append([cs + 1, name])
for key: String in DP.get_all():
var s := _fuzzy_score(ql, key.to_lower())
if s > 0:
scored.append([s, key])
scored.sort_custom(func(a: Array, b: Array) -> bool: return a[0] > b[0])
_suggestions.clear()
for i in mini(scored.size(), MAX_SUGGESTIONS):
_suggestions.append(scored[i][1])
for i in MAX_SUGGESTIONS:
if i < _suggestions.size():
_suggest_labels[i].text = _suggestion_text(_suggestions[i])
_suggest_labels[i].modulate = _suggestion_color(i)
_suggest_labels[i].visible = true
else:
_suggest_labels[i].visible = false
_suggest_panel.visible = not _suggestions.is_empty()
func _is_command(name: String) -> bool:
return _COMMANDS.has(name)
func _suggestion_text(name: String) -> String:
if _is_command(name):
return " %s [cmd] %s" % [name.rpad(10), _COMMANDS[name]]
var meta: Dictionary = DP.get_all()[name]
return " %s %s [%s]" % [name.rpad(28), _fmt_val(name, meta).rpad(8), meta["section"]]
func _suggestion_color(idx: int) -> Color:
if idx == _selected_idx:
return Color(1.0, 1.0, 0.4) # selected — yellow
if idx == 0:
return Color(0.85, 1.0, 0.70) # top match — bright green
return Color(0.60, 0.78, 0.50) # rest — muted green
func _fuzzy_score(query: String, target: String) -> int:
var qi := 0
var score := 0
var last := -1
for ti in target.length():
if qi >= query.length():
break
if target[ti] == query[qi]:
score += 10 + (5 if ti == last + 1 else 0)
last = ti
qi += 1
if qi < query.length():
return 0 # not all query chars matched
if target.begins_with(query):
score += 50
if target == query:
score += 100
return score
func _fmt_val(_key: String, meta: Dictionary) -> String:
if meta["type"] == TYPE_FLOAT:
return _fmt_num(meta["value"] as float)
if meta["type"] == TYPE_BOOL:
var bval: bool = meta["value"]
return "true" if bval else "false"
return str(meta["value"])
# Render a float as a plain decimal — trailing zeros trimmed but always at least one
# place, so it always reads as a float (4.0, 0.4, 0.005) never an int (4).
func _fmt_num(v: float) -> String:
var s := ("%.4f" % v).rstrip("0")
if s.ends_with("."):
s += "0"
return s
func _clear_suggestions() -> void:
_suggestions.clear()
_selected_idx = -1
for lbl in _suggest_labels:
lbl.visible = false
_suggest_panel.visible = false
# ↑/↓ move through the suggestion list while it's open; once it's gone they recall
# command history instead. Same keys, context decides — no separate binding to learn.
func _arrow(dir: int) -> void:
if _suggest_panel.visible and not _suggestions.is_empty():
_move_selection(dir)
else:
_history_step(-dir)
# Highlight the next/prev suggestion and drop "<name> <value>" into the field with the
# value pre-selected, so you just type your number over it.
func _move_selection(dir: int) -> void:
if _selected_idx < 0:
_selected_idx = 0 if dir > 0 else _suggestions.size() - 1
else:
_selected_idx = wrapi(_selected_idx + dir, 0, _suggestions.size())
_recolor_suggestions()
_fill_selected()
# Tab commits the highlighted suggestion (top match if none yet) and closes the list.
func _accept_completion() -> void:
if _suggestions.is_empty():
return
_selected_idx = maxi(_selected_idx, 0)
_fill_selected()
_clear_suggestions()
func _fill_selected() -> void:
var key := _suggestions[_selected_idx]
# A command drops in as "verb " ready for its argument (or a bare Enter); a
# param drops in as "key value" with the value pre-selected to type over.
# Suggestions stay open either way so ↑↓ keeps cycling.
if _is_command(key):
_input_field.text = key + " "
_input_field.caret_column = _input_field.text.length()
return
var meta: Dictionary = DP.get_all()[key]
_input_field.text = "%s %s" % [key, _fmt_val(key, meta)]
_input_field.caret_column = _input_field.text.length()
_input_field.select(key.length() + 1)
func _recolor_suggestions() -> void:
for i in MAX_SUGGESTIONS:
if _suggest_labels[i].visible:
_suggest_labels[i].modulate = _suggestion_color(i)
# ↑ recalls older commands, ↓ newer ones; index -1 is the live (in-progress) line,
# which is stashed on the first ↑ and restored when you step back down to it.
func _history_step(dir: int) -> void:
if _history.is_empty():
return
var target := _history_idx + dir
if target < -1 or target >= _history.size():
return
if _history_idx == -1 and dir > 0:
_pending_line = _input_field.text
_history_idx = target
_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])
# Wipe the live line (Ctrl+C) without submitting — also drops any suggestion popup
# and resets history browsing so the next ↑ starts fresh.
func _clear_input() -> void:
_input_field.clear()
_history_idx = -1
_pending_line = ""
_clear_suggestions()
func _set_input(text: String) -> void:
_input_field.text = text
_input_field.caret_column = text.length()
_clear_suggestions()
# ── Command execution ─────────────────────────────────────────────────────────
func _on_submitted(raw: String) -> void:
raw = raw.strip_edges()
if raw.is_empty():
return
if _history.is_empty() or _history[0] != raw:
_history.push_front(raw)
_history_idx = -1
_pending_line = ""
_input_field.clear()
_clear_suggestions()
_execute(raw)
func _execute(cmd: String) -> void:
_log_raw("[color=#3a5a3a]> %s[/color]" % _esc(cmd))
var parts := cmd.split(" ", false)
if parts.is_empty():
return
if _run_command(parts):
return
# Not a command — treat as a param key, with fuzzy fall-back for partial names.
var key := _resolve_key(parts[0])
if key.is_empty():
return
var meta: Dictionary = DP.get_all()[key]
if parts.size() == 1:
_read_param(key, meta)
else:
_set_param(key, meta, parts[1])
# Run a built-in command; returns false if parts[0] isn't one (caller falls through
# to param handling). Kept separate from _execute to keep each dispatch shallow.
func _run_command(parts: PackedStringArray) -> bool:
match parts[0].to_lower():
"save":
DP.save()
_log_ok("Saved.")
"reset":
_cmd_reset(parts)
"toggle":
_cmd_toggle(parts)
"diff":
_cmd_diff()
"list":
_cmd_list(parts)
"level":
_cmd_level(parts)
"restart":
_log_ok("Restarting…")
get_tree().paused = false
get_tree().reload_current_scene()
"clear":
_log.clear()
"help":
_cmd_help()
_:
return false
return true
# Resolve a user-typed token to a param key: exact match, else unique fuzzy match.
# Logs an ambiguity / unknown warning (and an arrow on a fuzzy hit) and returns
# "" when it can't narrow to a single key.
func _resolve_key(raw: String) -> String:
if DP.get_all().has(raw):
return raw
var matches := _fuzzy_find_all(raw)
if matches.size() == 1:
_log_raw("[color=#8888ff]→ %s[/color]" % matches[0])
return matches[0]
if matches.size() > 1:
_log_warn("Ambiguous '%s': %s" % [_esc(raw), ", ".join(matches.slice(0, 6))])
else:
_log_warn("Unknown: %s" % _esc(raw))
return ""
func _cmd_reset(parts: PackedStringArray) -> void:
if parts.size() == 1:
DP.reset_all()
_log_ok("All params reset to defaults.")
return
var key := _resolve_key(parts[1])
if key.is_empty():
return
DP.reset(key)
_log_ok("[b]%s[/b] reset to [color=#ffe080]%s[/color]" % [key, _fmt_val(key, DP.get_all()[key])])
func _cmd_toggle(parts: PackedStringArray) -> void:
if parts.size() < 2:
_log_warn("Usage: toggle <bool param>")
return
var key := _resolve_key(parts[1])
if key.is_empty():
return
var meta: Dictionary = DP.get_all()[key]
if meta["type"] != TYPE_BOOL:
_log_warn("%s is not a bool — use '%s <value>'" % [key, key])
return
var v := not (meta["value"] as bool)
DP.set_value(key, v)
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, "true" if v else "false"])
# List only params that currently differ from their registered default, grouped by
# section — the "what have I actually changed?" view before a save.
func _cmd_diff() -> void:
var all := DP.get_all()
var sections: Dictionary = {}
for key: String in all:
if not DP.is_modified(key):
continue
var sec: String = all[key]["section"]
if not sections.has(sec):
sections[sec] = []
sections[sec].append(key)
if sections.is_empty():
_log_ok("All params at defaults.")
return
for sec: String in sections:
_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
for key: String in sections[sec]:
var meta: Dictionary = all[key]
_log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color] [color=#484848](def %s)[/color]" % [
key, _fmt_val(key, meta), _fmt_default(meta)
])
func _cmd_help() -> void:
_log_raw("[color=#888888] <name>[/color] — read current value + range")
_log_raw("[color=#888888] <name> <value>[/color] — set it (number, or true/false)")
for name: String in _COMMANDS:
_log_raw("[color=#888888] %s[/color] — %s" % [name.rpad(20), _COMMANDS[name]])
_log_raw(" [color=#555555]↑↓ pick suggestion · Tab complete · ↑↓ history when empty · ` close[/color]")
func _fmt_default(meta: Dictionary) -> String:
if meta["type"] == TYPE_FLOAT:
return _fmt_num(meta["default"] as float)
return "true" if (meta["default"] as bool) else "false"
func _read_param(key: String, meta: Dictionary) -> void:
if meta["type"] == TYPE_FLOAT:
_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](%s … %s)[/color]" % [
key, _fmt_val(key, meta),
_fmt_num(meta["min"] as float), _fmt_num(meta["max"] as float),
])
else:
_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](true / false)[/color]" % [
key, _fmt_val(key, meta),
])
func _set_param(key: String, meta: Dictionary, raw: String) -> void:
if meta["type"] == TYPE_FLOAT:
var lo := meta["min"] as float
var hi := meta["max"] as float
if not raw.is_valid_float() or not is_finite(raw.to_float()):
_log_warn("%s wants a number" % key)
return
var v := raw.to_float()
DP.set_value(key, v)
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, _fmt_num(v)])
elif meta["type"] == TYPE_BOOL:
match raw.to_lower():
"1", "true", "yes", "on":
DP.set_value(key, true)
_log_ok("[b]%s[/b] ← [color=#ffe080]true[/color]" % key)
"0", "false", "no", "off":
DP.set_value(key, false)
_log_ok("[b]%s[/b] ← [color=#ffe080]false[/color]" % key)
_:
_log_warn("%s wants true or false" % key)
else:
_log_warn("Cannot set param of type %d" % meta["type"])
func _cmd_level(parts: PackedStringArray) -> void:
if parts.size() == 1:
var cur := Run.active_level()
var cur_name := (cur.id as String) if cur != null else "none"
_log_ok("Current level: [b]%s[/b]" % cur_name)
var ids: Array[StringName] = Run.level_ids()
_log_raw("Available: " + " ".join(ids.map(func(id: StringName) -> String: return str(id))))
return
var id := StringName(parts[1].to_lower())
var level := Run.get_level(id)
if level == null:
var known := " ".join(Run.level_ids().map(func(i: StringName) -> String: return str(i)))
_log_warn("Unknown level '%s'. Try: %s" % [parts[1], known])
return
Run.debug_level_override = level
_log_ok("Level set to [b]%s[/b] — reloading…" % str(id))
get_tree().paused = false
get_tree().change_scene_to_file(level.scene_path)
func _cmd_list(parts: PackedStringArray) -> void:
var filter := parts[1].to_lower() if parts.size() > 1 else ""
var all := DP.get_all()
var sections: Dictionary = {}
for key: String in all:
var sec: String = all[key]["section"]
if filter.length() > 0:
if not sec.to_lower().begins_with(filter) and not key.to_lower().contains(filter):
continue
if not sections.has(sec):
sections[sec] = []
sections[sec].append(key)
for sec: String in sections:
_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
for key: String in sections[sec]:
_log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color]" % [
key, _fmt_val(key, all[key])
])
func _fuzzy_find_all(query: String) -> Array[String]:
var result: Array[String] = []
var ql := query.to_lower()
for key: String in DP.get_all():
if _fuzzy_score(ql, key.to_lower()) > 0:
result.append(key)
return result
# ── Logging ───────────────────────────────────────────────────────────────────
## Neutralise BBCode in user-supplied text before it reaches the RichTextLabel,
## so malformed or hostile tags (e.g. [img]) render literally instead of running.
func _esc(text: String) -> String:
return text.replace("[", "[lb]")
func _log_raw(text: String) -> void:
if _log.get_line_count() > LOG_MAX_LINES:
_log.clear()
_log.append_text(text + "\n")
func _log_ok(text: String) -> void:
_log_raw("[color=#66ee88]%s[/color]" % text)
func _log_warn(text: String) -> void:
_log_raw("[color=#ff7744]%s[/color]" % text)
+1
View File
@@ -0,0 +1 @@
uid://via815ow6p5r
+537
View File
@@ -0,0 +1,537 @@
extends Node
## Runtime debug overlays (autoload "DebugDraw").
##
## Two mechanisms, both gated by DP "Debug" bool params so they cost nothing when off:
## • Immediate-mode 3D lines — call DebugDraw.line()/ray() each frame; flushed once
## per frame into a single ImmediateMesh. Used for raycasts and the bone overlay.
## • Shape overlays — a MeshInstance3D attached to each CollisionShape3D, reconciled
## incrementally so static geometry is never re-instanced and freshly spawned
## bodies get picked up within one interval.
##
## Toggle from the console:
## show_hitboxes · show_attacks · show_bones · show_raycasts (true / false)
## show_hitboxes — the combatants' hurt volumes: each HitArea + the player/enemy body
## capsules, drawn as calm translucent-blue volumes (what abilities/blades hit)
## show_attacks — the attack hitboxes (bear claw/smash/leap, matador blade, thrown
## spear), filled translucent red and shown ONLY while the swing is actually live
## show_states — floating AI-state tags + velocity vectors over each matador
## show_animation_name — floating current-clip tag over every AnimationPlayer host
## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally
## show_grid — 1 m reference grid on the ground plane around the origin
## show_axes — RGB world-axis gizmo at the origin
## show_wireframe — render the whole viewport in wireframe
const REBUILD_INTERVAL := 0.5
const STATS_INTERVAL := 0.2 # stats text is re-composed 5×/sec, not every frame
const COLOR_AREA := Color(0.0, 0.9, 1.0) # combatant hurt volumes (cyan, translucent)
const COLOR_ATTACK := Color(1.0, 0.30, 0.15) # attack hitboxes (red, translucent, overlaid)
const COLOR_BONE := Color(1.0, 0.35, 0.9) # skeleton bones (magenta)
const COLOR_VEL := Color(1.0, 0.85, 0.2) # matador velocity vectors (amber)
const COLOR_ANIM := Color(0.55, 0.85, 1.0) # animation-name tags (sky blue)
const COLOR_GRID := Color(0.45, 0.45, 0.45, 0.5)
const AREA_ALPHA := 0.25
const ATTACK_ALPHA := 0.32
const GRID_HALF := 20 # grid extends ±GRID_HALF cells from the origin
const GRID_STEP := 1.0 # metres between grid lines
const AXIS_LEN := 3.0 # length of each world-axis gizmo arm
var _line_mesh: ImmediateMesh
var _lines: PackedVector3Array = PackedVector3Array()
var _colors: PackedColorArray = PackedColorArray()
var _overlays: Dictionary = {} # CollisionShape3D instance id -> MeshInstance3D
var _skeletons: Array[Skeleton3D] = []
var _anim_players: Array[AnimationPlayer] = []
var _reconcile_t: float = 0.0
var _prev_flags: String = ""
var _state_labels: Dictionary = {} # matador instance id -> Label3D
var _anim_labels: Dictionary = {} # AnimationPlayer instance id -> Label3D
var _stats_layer: CanvasLayer = null
var _stats_label: Label = null
var _stats_t: float = 0.0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS # keep drawing while the console pauses the game
process_priority = 1000 # flush after everyone has queued their lines
# One-time: wireframe debug-draw needs the wireframe index buffers generated.
RenderingServer.set_debug_generate_wireframes(true)
_line_mesh = ImmediateMesh.new()
var inst := MeshInstance3D.new()
inst.mesh = _line_mesh
inst.material_override = _line_material()
inst.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(inst)
# ── Public immediate-mode API ──────────────────────────────────────────────────
func line(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
_lines.append(from)
_lines.append(to)
_colors.append(color)
_colors.append(color)
func ray(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
line(from, to, color)
# ── Frame flush ────────────────────────────────────────────────────────────────
func _process(delta: float) -> void:
_reconcile(delta)
_pulse_attacks()
if DP.b("show_bones"):
_draw_bones()
if DP.b("show_states"):
_update_state_labels()
elif not _state_labels.is_empty():
_clear_state_labels()
if DP.b("show_animation_name"):
_update_anim_labels()
elif not _anim_labels.is_empty():
_clear_anim_labels()
if DP.b("show_grid"):
_draw_grid()
if DP.b("show_axes"):
_draw_axes()
_update_wireframe()
_update_stats(delta)
_flush_lines()
func _flush_lines() -> void:
_line_mesh.clear_surfaces()
if not _lines.is_empty():
_line_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
for i in _lines.size():
_line_mesh.surface_set_color(_colors[i])
_line_mesh.surface_add_vertex(_lines[i])
_line_mesh.surface_end()
_lines.clear()
_colors.clear()
# ── Bone overlay ───────────────────────────────────────────────────────────────
func _draw_bones() -> void:
for sk in _skeletons:
if not is_instance_valid(sk):
continue
var gx := sk.global_transform
for b in sk.get_bone_count():
var parent := sk.get_bone_parent(b)
if parent < 0:
continue
var a := gx * sk.get_bone_global_pose(parent).origin
var c := gx * sk.get_bone_global_pose(b).origin
line(a, c, COLOR_BONE)
# ── Matador AI-state overlay ───────────────────────────────────────────────────
# A billboarded state tag hovers over each matador, coloured by state, with an
# amber velocity vector — reads the whole arena's AI at a glance while tuning combat.
func _update_state_labels() -> void:
var seen: Dictionary = {}
for node: Node in get_tree().get_nodes_in_group(&"matador"):
var m := node as Node3D
if m == null:
continue
var id := m.get_instance_id()
seen[id] = true
var lbl: Label3D = _state_labels.get(id)
if lbl == null:
lbl = _new_state_label()
_state_labels[id] = lbl
var state_name := "?"
if m.has_method(&"ai_state_name"):
state_name = m.call(&"ai_state_name")
lbl.text = state_name
lbl.modulate = _state_color(state_name)
lbl.global_position = m.global_position + Vector3.UP * 2.2
if m is CharacterBody3D:
var v: Vector3 = (m as CharacterBody3D).velocity
v.y = 0.0
if v.length() > 0.5:
var base := m.global_position + Vector3.UP * 0.15
line(base, base + v * 0.18, COLOR_VEL)
for id: int in _state_labels.keys():
if not seen.has(id):
var lbl: Label3D = _state_labels[id]
if is_instance_valid(lbl):
lbl.queue_free()
_state_labels.erase(id)
func _new_state_label() -> Label3D:
var lbl := Label3D.new()
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.fixed_size = true
lbl.pixel_size = 0.0006
lbl.font_size = 64
lbl.outline_size = 12
lbl.outline_modulate = Color(0.0, 0.0, 0.0, 0.9)
add_child(lbl)
return lbl
func _clear_state_labels() -> void:
for id: int in _state_labels:
var lbl: Label3D = _state_labels[id]
if is_instance_valid(lbl):
lbl.queue_free()
_state_labels.clear()
const _STATE_COLORS := {
"WANDER": Color(0.70, 0.70, 0.70),
"FLEE": Color(0.40, 0.80, 1.00),
"BRACE": Color(1.00, 0.90, 0.30),
"SIDESTEP": Color(1.00, 0.60, 0.10),
"ATTACK": Color(1.00, 0.30, 0.30),
"ROLL": Color(0.60, 1.00, 0.60),
"THROW": Color(1.00, 0.40, 0.90),
"RAGDOLL": Color(0.40, 0.40, 0.40),
}
func _state_color(state_name: String) -> Color:
return _STATE_COLORS.get(state_name, Color.WHITE)
# ── Animation-name overlay ─────────────────────────────────────────────────────
# A billboarded tag showing the clip each AnimationPlayer is currently playing,
# hovering above its nearest Node3D host — catches wrong / stuck animation states
# at a glance. Players are re-collected on the reconcile tick (see _collect).
func _update_anim_labels() -> void:
var seen: Dictionary = {}
for ap in _anim_players:
if not is_instance_valid(ap):
continue
var clip := ap.current_animation
# Skip finished / dormant sub-players so a host with several AnimationPlayers
# (e.g. a matador's main + olé + death players) doesn't stack empty tags.
if clip.is_empty() and not ap.is_playing():
continue
var host := _node3d_host(ap)
if host == null:
continue
var id := ap.get_instance_id()
seen[id] = true
var lbl: Label3D = _anim_labels.get(id)
if lbl == null:
lbl = _new_state_label()
lbl.modulate = COLOR_ANIM
_anim_labels[id] = lbl
lbl.text = clip if not clip.is_empty() else "—"
lbl.global_position = host.global_position + Vector3.UP * 2.6
for id: int in _anim_labels.keys():
if not seen.has(id):
var lbl: Label3D = _anim_labels[id]
if is_instance_valid(lbl):
lbl.queue_free()
_anim_labels.erase(id)
func _clear_anim_labels() -> void:
for id: int in _anim_labels:
var lbl: Label3D = _anim_labels[id]
if is_instance_valid(lbl):
lbl.queue_free()
_anim_labels.clear()
# Nearest Node3D at or above `node` — the anchor an AnimationPlayer's tag hovers over.
func _node3d_host(node: Node) -> Node3D:
var n := node
while n != null:
if n is Node3D:
return n as Node3D
n = n.get_parent()
return null
# ── World reference overlays ───────────────────────────────────────────────────
func _draw_grid() -> void:
var extent := GRID_HALF * GRID_STEP
for i in range(-GRID_HALF, GRID_HALF + 1):
var o := i * GRID_STEP
line(Vector3(o, 0.01, -extent), Vector3(o, 0.01, extent), COLOR_GRID)
line(Vector3(-extent, 0.01, o), Vector3(extent, 0.01, o), COLOR_GRID)
func _draw_axes() -> void:
line(Vector3.ZERO, Vector3.RIGHT * AXIS_LEN, Color.RED) # +X
line(Vector3.ZERO, Vector3.UP * AXIS_LEN, Color.GREEN) # +Y
line(Vector3.ZERO, Vector3(0.0, 0.0, 1.0) * AXIS_LEN, Color.DODGER_BLUE) # +Z
func _update_wireframe() -> void:
var vp := get_viewport()
var want := Viewport.DEBUG_DRAW_WIREFRAME if DP.b("show_wireframe") else Viewport.DEBUG_DRAW_DISABLED
if vp.debug_draw != want:
vp.debug_draw = want
# ── Stats overlay ──────────────────────────────────────────────────────────────
# A 2D corner readout of frame cost + scene load, plus a per-state matador tally so
# a combat slowdown or a stuck AI swarm shows up immediately.
func _update_stats(delta: float) -> void:
if not DP.b("show_stats"):
if _stats_layer != null:
_stats_layer.visible = false
return
_ensure_stats_ui()
_stats_layer.visible = true
_stats_t -= delta
if _stats_t > 0.0:
return
_stats_t = STATS_INTERVAL
_stats_label.text = _compose_stats()
func _ensure_stats_ui() -> void:
if _stats_layer != null:
return
var mono := SystemFont.new()
mono.font_names = PackedStringArray(["JetBrains Mono", "DejaVu Sans Mono", "monospace"])
_stats_layer = CanvasLayer.new()
_stats_layer.layer = 64
add_child(_stats_layer)
var panel := PanelContainer.new()
panel.position = Vector2(8.0, 8.0)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.0, 0.0, 0.0, 0.55)
bg.set_content_margin_all(6.0)
panel.add_theme_stylebox_override("panel", bg)
_stats_layer.add_child(panel)
_stats_label = Label.new()
_stats_label.add_theme_font_override("font", mono)
_stats_label.add_theme_font_size_override("font_size", 13)
_stats_label.add_theme_color_override("font_color", Color(0.60, 1.0, 0.60))
panel.add_child(_stats_label)
func _compose_stats() -> String:
var fps := int(Performance.get_monitor(Performance.TIME_FPS))
var proc := Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
var phys := Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
var draws := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
var prims := int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
var vmem := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0)
var nodes := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
var phys3d := int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
return "fps %d proc %.1fms phys %.1fms\ndraws %d prims %s vmem %dMB\nnodes %d phys3d %d\n%s" % [
fps, proc, phys, draws, _si(prims), vmem, nodes, phys3d, _matador_tally()]
func _matador_tally() -> String:
var mats := get_tree().get_nodes_in_group(&"matador")
if mats.is_empty():
return "matadors 0"
var counts: Dictionary = {}
for node: Node in mats:
var s := "?"
if node.has_method(&"ai_state_name"):
s = node.call(&"ai_state_name")
counts[s] = int(counts.get(s, 0)) + 1
var parts: PackedStringArray = PackedStringArray()
for s: String in counts:
parts.append("%s:%d" % [s.substr(0, 2), counts[s]])
return "matadors %d %s" % [mats.size(), " ".join(parts)]
func _si(n: int) -> String:
if n >= 1000000:
return "%.1fM" % (n / 1000000.0)
if n >= 1000:
return "%.0fk" % (n / 1000.0)
return str(n)
# ── Collision / hit-area overlays (incremental) ────────────────────────────────
func _reconcile(delta: float) -> void:
var hit := DP.b("show_hitboxes")
var atk := DP.b("show_attacks")
var bone := DP.b("show_bones")
var anim := DP.b("show_animation_name")
if not (hit or atk or bone or anim):
if not _overlays.is_empty():
_clear_overlays()
_skeletons.clear()
_anim_players.clear()
_prev_flags = ""
return
var flags := "%d%d%d%d" % [int(hit), int(atk), int(bone), int(anim)]
_reconcile_t -= delta
if flags != _prev_flags: # a toggle just changed — refresh immediately
_prev_flags = flags
_reconcile_t = 0.0
if _reconcile_t > 0.0:
return
_reconcile_t = REBUILD_INTERVAL
_skeletons.clear()
_anim_players.clear()
var wanted: Dictionary = {}
var scene := get_tree().current_scene
if scene != null:
_collect(scene, hit, atk, bone, anim, wanted)
# Drop overlays whose shape is gone or no longer wanted. The overlay is a child of
# the collision shape, so it's freed with it — hold it as Variant, since assigning a
# freed instance to any *typed* var (even Object) throws before is_instance_valid()
# can filter it out.
for id: int in _overlays.keys():
var mi: Variant = _overlays[id]
if not wanted.has(id) or not is_instance_valid(mi):
if is_instance_valid(mi):
(mi as Node).queue_free()
_overlays.erase(id)
# Add overlays for newly seen shapes.
for id: int in wanted:
if not _overlays.has(id):
var entry: Array = wanted[id]
_overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as String)
func _collect(node: Node, hit: bool, atk: bool, bone: bool, anim: bool,
wanted: Dictionary) -> void:
# The audience ("public") is ambient decoration, not a debug target — skip the whole
# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
if node.is_in_group(&"public"):
return
if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
_skeletons.append(node as Skeleton3D)
if anim and node is AnimationPlayer:
_anim_players.append(node as AnimationPlayer)
if node is CollisionShape3D:
var cs := node as CollisionShape3D
if cs.shape != null and not cs.disabled and _drawable(cs.shape):
var kind := _classify(cs.get_parent())
var show := (kind == "attack" and atk) or (kind == "hitbox" and hit)
if show:
wanted[cs.get_instance_id()] = [cs, kind]
for child in node.get_children():
_collect(child, hit, atk, bone, anim, wanted)
# Sort a shape's owning body/area into an overlay bucket, or "" to skip.
# attack — a swing volume (a Hitbox area, or a thrown-spear projectile body)
# hitbox — a combatant's hurt volume: its HitArea, or its own body capsule
func _classify(parent: Node) -> String:
if parent is Hitbox or parent is SpearProjectile:
return "attack"
if parent is Area3D:
return "hitbox"
# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for the
# skeleton; otherwise every corpse floods the view with capsules.
if parent is PhysicsBody3D and not (parent is PhysicalBone3D):
if parent.is_in_group(&"player") or parent.is_in_group(&"matador"):
return "hitbox"
return ""
# Skip only the unbounded shapes whose debug mesh is meaningless/huge; concave arena
# geometry is fine — Godot caches each shape's debug mesh, so it's built at most once.
func _drawable(shape: Shape3D) -> bool:
return not (shape is WorldBoundaryShape3D or shape is HeightMapShape3D)
func _attach(cs: CollisionShape3D, kind: String) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var solid := _solid_mesh(cs.shape)
mi.mesh = solid if solid != null else cs.shape.get_debug_mesh()
if kind == "attack":
# Overlaid so a brief swing volume reads clearly on top of the action; its
# visibility is pulsed per-frame in _pulse_attacks so it only shows while live.
mi.material_override = _translucent_material(COLOR_ATTACK, ATTACK_ALPHA, true)
else:
# The calm translucent-blue volume — depth-tested so it sits in the world.
mi.material_override = _translucent_material(COLOR_AREA, AREA_ALPHA, false)
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
cs.add_child(mi)
return mi
# Attack overlays exist for as long as show_attacks is on, but a swing hitbox is only a
# hazard while its Hitbox is monitoring — so show each attack volume only when it's live.
# (A thrown spear has no monitoring gate; it stays visible for its whole flight.)
func _pulse_attacks() -> void:
for id: int in _overlays:
var mi: Variant = _overlays[id]
if not is_instance_valid(mi):
continue
var owner := (mi as Node).get_parent().get_parent()
if owner is Hitbox:
(mi as MeshInstance3D).visible = (owner as Area3D).monitoring
func _clear_overlays() -> void:
for id: int in _overlays:
var mi: Variant = _overlays[id]
if is_instance_valid(mi):
(mi as Node).queue_free()
_overlays.clear()
# ── Meshes & materials ─────────────────────────────────────────────────────────
# A filled mesh matching a primitive shape, for translucent volume overlays. Returns
# null for shapes without a clean solid equivalent (caller falls back to wireframe).
func _solid_mesh(shape: Shape3D) -> Mesh:
if shape is BoxShape3D:
var m := BoxMesh.new()
m.size = (shape as BoxShape3D).size
return m
if shape is SphereShape3D:
var m := SphereMesh.new()
m.radius = (shape as SphereShape3D).radius
m.height = m.radius * 2.0
return m
if shape is CapsuleShape3D:
var m := CapsuleMesh.new()
m.radius = (shape as CapsuleShape3D).radius
m.height = (shape as CapsuleShape3D).height
return m
if shape is CylinderShape3D:
var m := CylinderMesh.new()
m.top_radius = (shape as CylinderShape3D).radius
m.bottom_radius = (shape as CylinderShape3D).radius
m.height = (shape as CylinderShape3D).height
return m
return null
func _line_material() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true
mat.no_depth_test = true
return mat
func _translucent_material(color: Color, alpha: float, overlay: bool) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(color.r, color.g, color.b, alpha)
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
mat.no_depth_test = overlay
return mat
+1
View File
@@ -0,0 +1 @@
uid://cgty4al6aqey3
+569
View File
@@ -0,0 +1,569 @@
extends Node
## Central registry for all runtime-tunable parameters.
## Add a new param with one _reg_f() call in _register_all() — the menu picks it up automatically.
## Access: DP.f("key") • Persist: DP.save() / DP.load_saved() • Reset: DP.reset_all()
signal any_changed(key: String, value: Variant)
const SAVE_PATH := "user://debug_params.cfg"
# _params holds the full metadata (min/max/step/default/section/type) and is the
# authority the debug menu reads. _values is a flat key→value mirror so the hot
# f()/b() reads — called dozens of times per matador per physics frame — cost a
# single hash lookup instead of a nested dict access. Every write updates both.
var _params: Dictionary = {}
var _values: Dictionary = {}
func _ready() -> void:
_register_all()
func _register_all() -> void:
#Gamepad vibration
_reg_b("Gamepad", "gamepad_vibration", true)
#Level Unlocking
_reg_b("Gate", "gate_unlocked_bear", false)
# ── Movement ──────────────────────────────────────────────────────────────
# WASD/arrows drive the bull: move_accel ramps flat velocity toward max_speed in
# the input direction; move_friction bleeds it off when no direction is held.
_reg_f("Movement", "max_speed", 10.0, 2.0, 1000.0)
_reg_f("Movement", "move_accel", 120.0, 5.0, 600.0)
_reg_f("Movement", "move_friction", 90.0, 5.0, 600.0)
_reg_f("Movement", "jump_velocity", 4.5, 1.0, 200.0)
_reg_f("Movement", "visual_turn_speed", 12.0, 1.0, 200.0)
_reg_f("Movement", "bounce_restitution", 0.55, 0.0, 2.0, 0.05)
_reg_f("Movement", "bounce_min_impact", 1.0, 0.0, 20.0)
_reg_f("Movement", "wall_bounce_restitution", 0.6, 0.0, 1.5, 0.05)
_reg_f("Movement", "wall_min_bounce_speed", 6.0, 0.0, 30.0)
# ── Bull health ───────────────────────────────────────────────────────────
# The bull soaks bull_max_hp clean hits before it dies; each hit grants
# bull_hit_iframes seconds of invulnerability so a single pass can't drain it.
_reg_f("Bull", "bull_max_hp", 10.0, 1.0, 100.0, 1.0)
_reg_f("Bull", "bull_hit_iframes", 0.8, 0.0, 3.0, 0.1)
# Half-angle (deg) of the bull's horns-first cone: running into an enemy only wounds it when the
# bull's velocity points within this of the enemy (not a sideways brush). 90 = any contact at speed.
# (The dash ability bypasses this.)
_reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0)
# ── Dust speed thresholds ─────────────────────────────────────────────────
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
_reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0)
# ── Dust ──────────────────────────────────────────────────────────────────
_reg_f("Dust", "dust_lifetime", 0.9, 0.1, 4.0)
_reg_f("Dust", "dust_explosiveness", 0.35, 0.0, 1.0)
_reg_f("Dust", "dust_spread", 45.0, 5.0, 90.0)
_reg_f("Dust", "dust_gravity_y", -1.5,-15.0, 0.0)
_reg_f("Dust", "walk_scale_min", 0.20, 0.05, 3.0)
_reg_f("Dust", "walk_scale_max", 0.40, 0.05, 3.0)
_reg_f("Dust", "walk_vel_min", 0.8, 0.1, 12.0)
_reg_f("Dust", "walk_vel_max", 1.8, 0.1, 12.0)
_reg_f("Dust", "charge_scale_min", 0.35, 0.05, 6.0)
_reg_f("Dust", "charge_scale_max", 0.70, 0.05, 6.0)
_reg_f("Dust", "charge_vel_min", 2.8, 0.1, 20.0)
_reg_f("Dust", "charge_vel_max", 5.5, 0.1, 20.0)
# ── Tail ──────────────────────────────────────────────────────────────────
_reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0)
_reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01)
_reg_f("Tail", "tail_body_radius", 0.35, 0.05, 2.0, 0.05)
_reg_f("Tail", "tail_wag_freq", 4.0, 0.5, 15.0)
_reg_f("Tail", "tail_wag_idle", 0.3, 0.0, 2.0, 0.05)
# ── Charge pose (horns-down lean during dash / roll) ─────────────────────
_reg_f("Charge", "charge_head_pitch", 0.3, 0.0, 1.2, 0.01)
_reg_f("Charge", "charge_head_speed", 5.0, 0.5, 20.0)
# ── Body ──────────────────────────────────────────────────────────────────
_reg_f("Body", "body_tilt_fwd", 0.015, 0.0, 0.1, 0.001)
_reg_f("Body", "body_tilt_side", 0.01, 0.0, 0.1, 0.001)
_reg_f("Body", "aerial_pitch_scale", 0.6, 0.0, 2.0)
_reg_f("Body", "aerial_pitch_speed", 5.0, 0.5, 20.0)
# ── Legs ──────────────────────────────────────────────────────────────────
_reg_f("Legs", "leg_step_threshold", 0.15, 0.01, 1.0, 0.01)
_reg_f("Legs", "leg_step_duration", 0.12, 0.02, 0.5, 0.01)
_reg_f("Legs", "leg_step_height", 0.15, 0.0, 0.5)
_reg_f("Legs", "leg_step_overshoot", 0.3, 0.0, 1.0)
_reg_f("Legs", "gait_freq", 1.5, 0.1, 5.0)
_reg_f("Legs", "launch_hold_time", 0.15, 0.0, 1.0, 0.01)
_reg_f("Legs", "ragdoll_wobble_amp", 0.15, 0.0, 1.0)
_reg_f("Legs", "tuck_apex_threshold", 0.5, -5.0, 5.0)
_reg_f("Legs", "jump_tuck_speed", 8.0, 1.0, 30.0)
_reg_f("Legs", "jump_tuck_up", 0.2, 0.0, 1.0)
_reg_f("Legs", "jump_tuck_fwd", -0.1, -0.5, 0.5)
_reg_f("Legs", "jump_land_threshold", 1.5, 0.5, 5.0)
# ── Camera ────────────────────────────────────────────────────────────────
_reg_f("Camera", "charge_zoom_factor", 0.65, 0.3, 1.0, 0.05)
_reg_f("Camera", "cam_lerp_power", 6.0, 0.5, 30.0)
_reg_f("Camera", "cam_sensitivity", 0.005, 0.001, 0.05, 0.001)
_reg_f("Camera", "cam_min_zoom", 3.5, 0.5, 20.0)
_reg_f("Camera", "cam_max_zoom", 40.0, 2.0, 80.0)
_reg_f("Camera", "cam_spring_length", 7.0, 1.0, 30.0)
_reg_f("Camera", "cam_min_vert_angle", -60.0, -90.0, 0.0, 1.0)
_reg_f("Camera", "cam_max_vert_angle", 45.0, 0.0, 90.0, 1.0)
# Trailing "drag" camera: the focus point lags the bull and slides opposite to
# its velocity, so the camera visibly drags behind during a fast charge.
# cam_drag = metres of trail per m/s of bull speed (0 = rigid follow)
# cam_drag_speed = how fast the trail offset eases toward its target
# cam_follow_lerp = how tightly the focus point chases the bull (higher = snappier)
_reg_f("Camera", "cam_drag", 0.12, 0.0, 1.0, 0.01)
_reg_f("Camera", "cam_drag_speed", 3.5, 0.5, 20.0)
_reg_f("Camera", "cam_follow_lerp", 10.0, 1.0, 40.0)
# ── Matador ───────────────────────────────────────────────────────────────
# Wave system: matadors enter the arena in escalating waves (1 → 2 → … → N where N
# is the number of placed spawn points). mat_spawn_stagger is the delay between each
# matador leaping in within a wave; mat_wave_pause is the breather after a wave clears.
_reg_f("Matador", "mat_wave_size", 8.0, 1, 50)
_reg_f("Matador", "mat_spawn_stagger", 0.4, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_wave_pause", 2.0, 0.0, 8.0, 0.25)
_reg_f("Matador", "mat_walk_speed", 5.5, 0.5, 10.0)
_reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0)
# Radius of the inner fighting floor: bull + matadors spawn on this ring (so
# they stay off the benches). Tune in the console to hug the bench edge.
_reg_f("Matador", "arena_spawn_radius", 10.0, 2.0, 22.0, 0.5)
_reg_f("Matador", "mat_idle_min", 1.2, 0.0, 5.0, 0.1)
_reg_f("Matador", "mat_idle_max", 4.0, 0.5, 10.0, 0.1)
_reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0)
# Planar reach at which the bull's swept charge path gores a matador (~bull + HitArea radii).
# Raise for a more forgiving charge, lower for a more precise one.
_reg_f("Matador", "mat_charge_gore_radius", 1.2, 0.5, 3.0, 0.05)
_reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0)
# Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its
# shape; higher angular damp settles the flail instead of flopping flat.
_reg_f("Matador", "mat_joint_swing", 35.0, 0.0, 180.0, 1.0)
_reg_f("Matador", "mat_joint_twist", 40.0, 0.0, 180.0, 1.0)
_reg_f("Matador", "mat_ragdoll_damp", 1.5, 0.0, 10.0, 0.1)
_reg_f("Matador", "mat_flee_range", 3.5, 2.0, 40.0)
_reg_f("Matador", "mat_flee_speed", 7.0, 0.5, 12.0)
_reg_f("Matador", "mat_charge_speed", 10.0, 1.0, 30.0)
_reg_f("Matador", "mat_brace_range", 5.0, 2.0, 20.0)
_reg_f("Matador", "mat_brace_duration", 0.7, 0.05, 1.5, 0.05)
_reg_f("Matador", "mat_commit_dist", 2.5, 0.5, 6.0, 0.1)
_reg_f("Matador", "mat_step_speed", 6.5, 2.0, 20.0)
_reg_f("Matador", "mat_step_duration", 0.35, 0.1, 1.0, 0.05)
# How strongly the sidestep biases toward the bull so the blade sweeps through
# its path during the pass (0 = pure lateral dodge, 1 = straight at the bull).
_reg_f("Matador", "mat_pass_lunge", 0.35, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_dodge_cooldown", 2.5, 0.5, 12.0, 0.25)
_reg_f("Matador", "mat_dodge_chance", 0.72, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0)
_reg_f("Matador", "mat_attack_speed", 7.5, 1.0, 12.0)
_reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1)
# Reach of the reach-based melee hit and its re-strike gap. This now governs ONLY the sidestep
# PASS (mat charges the bull past with the blade out): a fast lateral sweep where the thin blade
# Area would tunnel, so a center-to-center distance check is the reliable gore. The stationary
# ATTACK stab no longer uses this — it drives off the sword's BladeHitbox collider (see below).
_reg_f("Matador", "mat_stab_reach", 2.0, 0.5, 4.0, 0.1)
# Distance the matador plants at to swing. The ATTACK stab damages off the animated BladeHitbox,
# whose thrust reaches ~3.75 m at full extension — so this MUST stay under that or the blade never
# touches the bull and the stab whiffs. 3.5 m keeps the matador clear of the bull's ~1.8 m-long
# body while the thrust still lands. Raise toward 3.75 for more gap, lower to stab from closer.
_reg_f("Matador", "mat_stab_standoff", 3.5, 0.5, 4.0, 0.1)
# Fraction through the Attack clip at which the blade's collider is armed. The clip is guard →
# wind-up (blade pulled back, phase ~0.3-0.5) → thrust (blade drives out to ~3.5 m, phase ~0.55+).
# Arming at the thrust keeps the bull from being gored during the wind-up (blade not yet extended)
# or the guard pose. The BladeHitbox overlap still decides the exact hit; this just gates timing.
_reg_f("Matador", "mat_stab_strike_at", 0.5, 0.0, 1.0, 0.05)
# Chance, each time a stab finishes, that the matador breaks off into something else (give ground,
# circle, or throw) instead of immediately stabbing again — keeps it from drilling the same thrust
# on repeat. 0 = always re-stab if in range, 1 = always break off after every stab.
_reg_f("Matador", "mat_stab_followup_chance", 0.6, 0.0, 1.0, 0.05)
# Beat after every stab where the brain won't re-commit to ATTACK, so it can't machine-gun the
# bull — it circles/gives ground instead. 0 = stab as fast as range allows.
_reg_f("Matador", "mat_stab_recovery", 0.9, 0.0, 3.0, 0.1)
# Chance, when a stab actually CONNECTS, that the matador breaks into a crowd-pleasing taunt and
# gives ground instead of pressing — milking the hit rather than spamming. High by design.
_reg_f("Matador", "mat_taunt_after_hit", 0.85, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_stab_cooldown", 0.6, 0.1, 3.0, 0.1)
# Half-angle (deg) the matador must be facing the bull within for a stab to land.
# Stops "sword's off to the side but I still died" — you're only gored when the
# blade is actually presented at you.
_reg_f("Matador", "mat_stab_arc", 55.0, 5.0, 180.0, 5.0)
# Chance a matador rolls clear (survives) instead of ragdolling when the bull's charge connects.
_reg_f("Matador", "mat_roll_chance", 0.8, 0.0, 1.0, 0.05)
# How much a committed charge pierces the survival roll: roll chance fades toward
# mat_roll_chance*(1-this) as bull speed climbs to mat_charge_speed. 1 = full-speed charge always
# gores. (A dash connect ignores the roll entirely.)
_reg_f("Matador", "mat_charge_pierce", 0.8, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05)
# Playback rate of the Draw_weapon clip used for both drawing and sheathing —
# higher = snappier unholster/holster.
_reg_f("Matador", "mat_draw_speed", 1.9, 0.25, 3.0, 0.05)
# Cross-fade between matador animation clips — smooths Run/Attack/Taunt cuts.
_reg_f("Matador", "mat_anim_blend", 0.12, 0.0, 0.5, 0.01)
# ── Matador heuristics (utility AI) ───────────────────────────────────────
# Every mat_decide_interval seconds the matador scores its options — dodge the
# pass, close for a strike, retreat, throw the blade, or strut/taunt — and picks
# the highest. These weights bias the personality: crank aggression for a reckless
# bullfighter, caution for a slippery one. mat_w_commit is a stickiness bonus for
# the current choice so it doesn't dither between two near-tied options.
_reg_f("Matador", "mat_decide_interval", 0.2, 0.05, 1.0, 0.05)
_reg_f("Matador", "mat_w_aggression", 1.1, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_w_caution", 1.0, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_w_flee", 0.8, 0.0, 3.0, 0.05)
# Showmanship (strut, circle the bull, taunt) — the supporting matadors lean on
# this to spread out and give the lead room, so a whole crowd reads as a bullring,
# not a swarm. Crank it for more posturing, drop it for a relentless mob.
_reg_f("Matador", "mat_w_showmanship", 1.6, 0.0, 3.0, 0.05)
# Weight on hurling the blade. Kept low — a thrown sword is an occasional flourish,
# not a barrage; raising this brings back the pelting.
_reg_f("Matador", "mat_w_throw", 0.35, 0.0, 3.0, 0.05)
# How many matadors press the bull at once. The nearest this-many engage (close,
# throw); everyone else keeps their distance and taunts, taking turns as the bull
# moves. 1 = strict one-on-one, higher = a bolder pack.
_reg_f("Matador", "mat_engage_slots", 2.0, 1.0, 8.0, 1.0)
# Preferred distance (m) a taunting matador circles the bull at while waiting its
# turn — the showmanship ring the crowd spreads out onto.
_reg_f("Matador", "mat_taunt_ring", 7.0, 3.0, 15.0, 0.5)
# How long (s) a matador safely out of the action holds a taunt before drifting to
# a new spot — much longer than the between-passes idle, so bystanders mostly pose
# for the crowd instead of milling about. Actual hold is randomised ±30%.
_reg_f("Matador", "mat_taunt_hold", 8.0, 1.0, 20.0, 0.5)
# Small so it smooths dithering without locking the matador into idling.
_reg_f("Matador", "mat_w_commit", 0.15, 0.0, 2.0, 0.05)
# ── Bear (boss) ─────────────────────────────────────────────────────────────
# A Dark-Souls-style boss with a learnable moveset. It soaks bear_max_hp clean hits
# (ram or ability), staggering on each, and only topples when worn down. From NEUTRAL
# it weighs its options by the range band to the bull and commits to one telegraphed
# routine (wind-up → active → recovery); each routine plays to the end (the recovery
# is the player's punish window). Below bear_enrage_frac HP it enters phase 2: faster
# tells and recovery (× bear_enrage_scale) and longer combo chains.
_reg_f("Bear", "bear_max_hp", 30.0, 1.0, 100.0, 1.0)
# HP fraction at which phase 2 ("Fury") triggers, and how much it multiplies every
# wind-up / recovery timer (lower = snappier, more dangerous).
_reg_f("Bear", "bear_enrage_frac", 0.4, 0.0, 1.0, 0.05)
_reg_f("Bear", "bear_enrage_scale", 0.7, 0.3, 1.0, 0.05)
# Range bands (m) that pick the moveset: ≤close it strikes (swipe / smash), ≤mid it
# walks in to close the gap (stalk) or leaps, beyond it lopes in (prowl) or leaps.
# close is deliberately generous — the bear commits to melee "from a bit further" and
# steps in during the swing (see bear_lunge_speed) rather than needing to be on top.
_reg_f("Bear", "bear_close_range", 5.0, 1.0, 12.0, 0.1)
_reg_f("Bear", "bear_mid_range", 10.0, 3.0, 20.0, 0.5)
_reg_f("Bear", "bear_long_range", 15.0, 3.0, 100.0, 0.5)
# Beat spent in NEUTRAL sizing up the bull between routines — kept short so the bear
# flows straight from one action into the next instead of pausing (raise for a more
# hesitant, readable boss; near-0 for relentless pressure). Shrinks again in phase 2.
_reg_f("Bear", "bear_think_time", 0.06, 0.0, 1.0, 0.01)
# Locomotion: the all-fours prowl (close a big gap), the menacing 2-leg walk-in
# (stalk), the facing rate while positioning/winding up, and the softer tracking rate
# a swing keeps on the bull mid-attack so it's harder to just walk out of.
_reg_f("Bear", "bear_run_speed", 11.0, 1.0, 20.0, 0.1)
_reg_f("Bear", "bear_stalk_speed", 5.5, 0.5, 12.0, 0.1)
_reg_f("Bear", "bear_turn_rate", 7.0, 1.0, 20.0, 0.5)
_reg_f("Bear", "bear_track_active", 3.5, 0.0, 12.0, 0.1)
# Forward step-in during a swing so a strike lands even when the bull is a touch out of
# reach (also carries the tracking); 0 = a purely stationary swing.
_reg_f("Bear", "bear_lunge_speed", 7.0, 0.0, 16.0, 0.5)
# Playback multiplier for the swipe/smash clips — >1 makes the punches snappier.
_reg_f("Bear", "bear_attack_anim_speed", 1.35, 0.5, 3.0, 0.05)
# Half-arc (deg) the bear must be facing the bull within for a directional swing to
# connect; slams use their own radius instead of an arc.
_reg_f("Bear", "bear_hit_arc", 70.0, 5.0, 180.0, 5.0)
# Double Swipe (ONE_TWO_HIT) — bread-and-butter close combo, two quick hits.
_reg_f("Bear", "bear_swipe_windup", 0.3, 0.05, 2.0, 0.05)
_reg_f("Bear", "bear_swipe_recover", 0.35, 0.05, 3.0, 0.05)
_reg_f("Bear", "bear_swipe_reach", 4.2, 1.0, 8.0, 0.1)
# Which claw-slash FX plays on the first vs second swipe hit — the L/R clips are easy to
# mix up, so 0 = first hit L then R, 1 = swap them. Purely cosmetic (no gameplay effect).
_reg_f("Bear", "bear_fx_claw_swap", 0.0, 0.0, 1.0, 1.0)
# Live yaw (deg) applied to the whole Bear_FX node before each effect fires, so the claw
# rake / debris can be spun to line up with the bear's facing by eye. Bake the good value.
_reg_f("Bear", "bear_fx_yaw", 0.0, -180.0, 180.0, 5.0)
# The baked crater clip only pops up (no shrink), so hold it on the ground this long after a
# leap slam before hiding it — otherwise it flashes for a single frame and reads as invisible.
_reg_f("Bear", "bear_fx_crater_linger", 0.8, 0.0, 3.0, 0.1)
# Overhead Smash (SMASH_HIT) — slower tell, biggest punish window, AoE on impact.
_reg_f("Bear", "bear_smash_windup", 0.6, 0.1, 3.0, 0.05)
_reg_f("Bear", "bear_smash_recover", 0.75, 0.1, 4.0, 0.05)
# The overhead smash's hitbox is a forward box lane whose size + travel are authored in
# BearAttackHitboxes.tscn's AnimationPlayer ("smash_travel"). Here we only set the upward
# pop the bull takes off its feet at the far end (it ramps 0→this with hit distance).
_reg_f("Bear", "bear_smash_knockup", 6.0, 0.0, 16.0, 0.5)
# Leap Slam (JUMP_SMASH, 47 frames @30fps) — timed to the clip by FRAME: it plays the
# crouch grounded, springs at the takeoff frame, and the arc is solved so the paws hit
# ground exactly on the impact frame (25), leaving the tail frames to settle on the
# ground — no finishing the anim in mid-air. The arc uses its own gravity so peak height
# and airtime are independent: bear_leap_height is the peak reached in that fixed airtime.
_reg_f("Bear", "bear_leap_takeoff_frame", 6.0, 0.0, 46.0, 1.0)
_reg_f("Bear", "bear_leap_impact_frame", 25.0, 1.0, 47.0, 1.0)
_reg_f("Bear", "bear_leap_height", 2.6, 0.5, 8.0, 0.1)
_reg_f("Bear", "bear_leap_recover", 0.35, 0.0, 4.0, 0.05)
_reg_f("Bear", "bear_leap_speed", 14.0, 4.0, 30.0, 0.5)
# Where the leap-slam launch zone starts, as a fraction of the circle radius. 0 = launch anywhere
# inside; raise to carve a grounded inner zone that only mauls (0.75 = only the outer quarter ring).
_reg_f("Bear", "bear_leap_ring_frac", 0.0, 0.0, 1.0, 0.05)
_reg_f("Bear", "bear_leap_knockup", 7.0, 0.0, 16.0, 0.5)
# Stalk (WALK_ON2LEGS) — how long it will keep walking the bull down before re-deciding.
_reg_f("Bear", "bear_stalk_time", 2.5, 0.0, 6.0, 0.1)
# Odds a close combo chains a follow-up smash after the swipe (phase 2 always chains).
_reg_f("Bear", "bear_combo_chance", 0.7, 0.0, 1.0, 0.05)
# Minimum bull speed (ram) that counts as a hit on the bear — a gentle nudge is
# shrugged off, only a real charge or an ability wounds it. (The bear itself never
# damages the bull by colliding — only its swings and slams hurt.)
_reg_f("Bear", "bear_hit_threshold", 6.0, 1.0, 30.0, 0.5)
# Planar reach at which a horns-first bull charge wounds the bear (~bear body radius + bull reach).
# The bull's swept path is tested against this, so a tunnelling or point-blank charge still lands.
_reg_f("Bear", "bear_ram_reach", 2.1, 0.5, 5.0, 0.1)
# How long (s) the bear is rocked back and can't act after taking a hit.
_reg_f("Bear", "bear_stagger_time", 0.5, 0.0, 2.0, 0.05)
# Poise: after a stagger the bear shrugs off staggers for this long (hits still wound
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
# ── Sword ─────────────────────────────────────────────────────────────────
# Local seating of the sword in the right hand (drawn / fighting).
# The blade model runs along its local +Z, but weapon_bone (like every rig bone)
# points down its local +Y — so identity leaves the blade jutting out sideways.
# rot_x = -90 maps the blade's +Z onto the bone's +Y so it thrusts straight along
# the bone; rot_y is then the free roll (crossguard tilt) about the blade axis.
_reg_f("Sword", "sword_pos_x", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_pos_y", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_pos_z", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rot_x", -90.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rot_y", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rot_z", 0.0, -180.0, 180.0, 1.0)
# Local seating of the sword in the hip holster (sheathed / wandering).
# Independent of the hand grip so each pose can be tuned without affecting the
# other. weapon_rest_bone sits on the left hip; these angle the blade down and
# out along the OUTSIDE of the left thigh (rot_x/rot_y aim the blade, rot_z rolls
# it about its own axis) so it trails back beside the leg instead of clipping it.
_reg_f("Sword", "sword_rest_pos_x", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_pos_y", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_pos_z", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_rot_x", -110.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rest_rot_y", -133.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rest_rot_z", 0.0, -180.0, 180.0, 1.0)
# ── Spear throw (matador ranged attack) ──────────────────────────────────
# Local seating of the spear in the throwing hand during the wind-up. Tune to
# seat the shaft in the grip without re-exporting; flight orientation is derived
# from the aim direction at release, not from this pose.
_reg_f("Sword", "spear_pos_x", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "spear_pos_y", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "spear_pos_z", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "spear_rot_x", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "spear_rot_y", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "spear_rot_z", 0.0, -180.0, 180.0, 1.0)
# Delay before a fresh sword reappears in the holster if one is ever lost.
_reg_f("Sword", "mat_resword_delay", 2.0, 0.0, 10.0, 0.5)
_reg_f("Sword", "mat_throw_range", 18.0, 5.0, 40.0)
_reg_f("Sword", "mat_throw_min_dist", 5.0, 2.0, 15.0, 0.1)
_reg_f("Sword", "mat_throw_windup", 0.5, 0.2, 2.0, 0.05)
_reg_f("Sword", "mat_throw_release", 0.55, 0.1, 0.95, 0.01)
# Horizontal speed of the thrown spear; the vertical launch is solved from this
# and the gravity below so the arc lands on the bull. Lower = a higher, slower lob.
_reg_f("Sword", "mat_throw_speed", 11.0, 5.0, 60.0)
# Gravity scale on the spear (higher = a more pronounced arc) and the vertical aim
# offset from the bull origin down onto its low collision body.
_reg_f("Sword", "mat_throw_gravity", 1.0, 0.1, 3.0, 0.05)
_reg_f("Sword", "mat_throw_aim_y", -0.45,-1.5, 0.5, 0.05)
# Aim scatter (± degrees) so thrown blades can be read and dodged on the move.
_reg_f("Sword", "mat_throw_spread", 11.0, 0.0, 30.0, 0.5)
_reg_f("Sword", "mat_throw_arm_cock", 70.0,-180.0, 180.0, 1.0)
_reg_f("Sword", "mat_throw_arm_release", -90.0,-180.0, 180.0, 1.0)
_reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0)
# ── Abilities ─────────────────────────────────────────────────────────────
_reg_f("Abilities", "kick_cooldown", 2, 0.2, 600, 0.1)
_reg_f("Abilities", "kick_range", 6.0, 0.5, 8.0, 0.1)
_reg_f("Abilities", "kick_strength", 12.0, 1.0, 30.0)
_reg_f("Abilities", "slam_cooldown", 12, 0.5, 600, 0.1)
_reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0)
# Descent gravity multiplier — higher = faster crash, less airtime.
_reg_f("Abilities", "slam_fall_mult", 3.0, 1.0, 8.0, 0.5)
# Upward launch velocity (m/s) given to a slammed matador's ragdoll — pops them
# skyward. ~7 ≈ a 2.5 m hop; 0 disables the launch.
_reg_f("Abilities", "slam_launch_up", 7.0, 0.0, 15.0, 0.5)
_reg_f("Abilities", "slam_range", 4.0, 1.0, 10.0, 0.1)
_reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0)
_reg_f("Abilities", "dash_cooldown", 3, 0.1, 600.0, 0.1)
_reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0)
# Exponential rate the dash bleeds from dash_speed back to cruise (max_speed).
# Higher = a snappier bullet that dies off quicker; lower = a longer glide.
_reg_f("Abilities", "dash_decel", 7.0, 0.5, 30.0, 0.5)
# ── Roll (homing chain ball) ──────────────────────────────────────────────
# The bull curls into a ball and ACCELERATES the longer it rolls, from
# roll_base_speed up to roll_max_speed over roll_rampup_time. It HOMES on the
# closest matador; on slamming one the ball POPS it (launched skyward via
# roll_knockup) then CHAINS to the next closest, up to roll_chain_count times.
# roll_turn is the homing turn rate. When no chain targets remain, the roll ends.
_reg_f("Roll", "roll_cooldown", 20, 0.5, 600, 0.1)
_reg_f("Roll", "roll_duration", 2.5, 0.5, 6.0, 0.1) # max roll time
_reg_f("Roll", "roll_chain_count", 2.0, 0.0, 8.0, 1.0) # extra matadors chained after the first
_reg_f("Roll", "roll_base_speed", 28.0, 5.0, 120.0) # speed at the start of the roll
_reg_f("Roll", "roll_max_speed", 95.0, 10.0, 200.0) # top speed once fully ramped
_reg_f("Roll", "roll_rampup_time", 2.0, 0.2, 6.0, 0.1) # time to reach top speed
_reg_f("Roll", "roll_turn", 7.0, 0.2, 20.0) # homing turn rate (higher = tighter tracking)
_reg_f("Roll", "roll_spin_radius", 1.4, 0.3, 4.0, 0.1) # ball radius for tumble spin (smaller = faster)
_reg_f("Roll", "roll_hit_radius", 2.4, 0.5, 6.0, 0.1) # contact radius that pops the ball
_reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) # horizontal launch on the pop
_reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
# PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect):
# screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a
# rolling scanline. Keys map 1:1 onto the shader uniforms; show_ps1 toggles the
# whole overlay. Ranges mirror the shader's hint_range so the console can't push
# a uniform out of bounds.
_reg_b("PS1", "show_ps1", true)
_reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0)
_reg_f("PS1", "ps1_color_depth", 4.5, 2.0, 8.0, 1.0)
_reg_f("PS1", "ps1_dither_strength", 1, 0.0, 2.0, 0.05)
_reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05)
_reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1)
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
_reg_b("Debug", "show_hitboxes", false)
_reg_b("Debug", "show_attacks", false)
_reg_b("Debug", "show_bones", false)
_reg_b("Debug", "show_raycasts", false)
_reg_b("Debug", "show_tail", false)
_reg_b("Debug", "show_stats", false)
_reg_b("Debug", "show_states", false)
_reg_b("Debug", "show_animation_name", false)
_reg_b("Debug", "show_wireframe", false)
_reg_b("Debug", "show_grid", false)
_reg_b("Debug", "show_axes", false)
# Force the on-screen touch controls (joystick + ability buttons) on even on a
# desktop build — otherwise they appear only when a touchscreen is detected.
_reg_b("Touch", "force_touch_controls", false)
# On-screen readout of live touch state (event count, last position, active zone) so
# touch problems can be diagnosed on the actual device, where no console is reachable.
_reg_b("Touch", "touch_debug", false)
# Rebuild skinned characters as rigid, bone-attached mesh pieces instead of GPU vertex
# skinning. This was a workaround for older Mali/ANGLE web GPUs where Compatibility skinning
# rendered characters invisible — but the Godot 4.7 web templates fixed that (confirmed
# on-device), so native skinning is now the default everywhere: it renders the bear's dense
# mesh without the rigid-seam holes AND drops the per-spawn SurfaceTool rebuild that was
# spiking the arena on matador spawn. Kept as an opt-in fallback (DP toggle, or ?skin=1 in
# the URL) in case a device is found where Compatibility skinning still fails.
_reg_b("Render", "use_rigid_skin", false)
func _reg_f(section: String, key: String, default: float,
min_val: float, max_val: float, step: float = 0.05) -> void:
_params[key] = {
"value": default,
"default": default,
"section": section,
"type": TYPE_FLOAT,
"min": min_val,
"max": max_val,
"step": step,
}
_values[key] = default
func _reg_b(section: String, key: String, default: bool) -> void:
_params[key] = {
"value": default,
"default": default,
"section": section,
"type": TYPE_BOOL,
}
_values[key] = default
## Read a float param. Panics on unknown key — intentional: typos should be loud.
func f(key: String) -> float:
return _values[key] as float
## Read a bool param.
func b(key: String) -> bool:
return _values[key] as bool
func set_value(key: String, value: Variant) -> void:
if not _params.has(key):
return
_params[key]["value"] = value
_values[key] = value
any_changed.emit(key, value)
## Returns the full metadata dict — used by Console to build UI.
func get_all() -> Dictionary:
return _params
func save() -> void:
var path := "res://systems/debug/debug_params.gd"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_error("DP.save: cannot open " + path)
return
var src := file.get_as_text()
file.close()
for key: String in _params:
var p: Dictionary = _params[key]
if p["value"] == p["default"]:
continue
var re := RegEx.new()
var literal := ""
if p["type"] == TYPE_FLOAT:
re.compile('(_reg_f\\("[^"]+",\\s*"%s",\\s*)([-\\d.e]+)' % key)
literal = _fmt_float(p["value"] as float)
else:
re.compile('(_reg_b\\("[^"]+",\\s*"%s",\\s*)(true|false)' % key)
literal = "true" if bool(p["value"]) else "false"
var m := re.search(src)
if m == null:
push_warning("DP.save: no _reg_ line found for '%s' — skipped" % key)
continue
# Splice the value in by index rather than RegEx.sub(): sub()'s replacement
# string parses "$n" as a backreference, so "$1"+"12.5" becomes "$112.5"
# (group 112 → PCRE2 error → mangled output). Indexed splice inserts the
# literal verbatim, exactly the value the user entered.
src = src.substr(0, m.get_start(2)) + literal + src.substr(m.get_end(2))
p["default"] = p["value"]
var wfile := FileAccess.open(path, FileAccess.WRITE)
if wfile == null:
push_error("DP.save: cannot write " + path)
return
wfile.store_string(src)
wfile.close()
# Godot's String % operator has no "g" conversion — "%.6g" % v slips past the
# compiler (v is not const) but emits the literal "%.6g" at runtime, which would be
# written straight into the source. "%s" gives the shortest round-tripping form,
# matching the value the user entered; the guard keeps it a float literal.
func _fmt_float(v: float) -> String:
var s := "%s" % v
if not ("." in s or "e" in s):
s += ".0"
return s
func load_saved() -> void:
var cfg := ConfigFile.new()
if cfg.load(SAVE_PATH) != OK:
return
for section: String in cfg.get_sections():
for key: String in cfg.get_section_keys(section):
if not _params.has(key):
continue
var p: Dictionary = _params[key]
var val: Variant = cfg.get_value(section, key)
if p["type"] == TYPE_FLOAT:
val = clampf(val as float, p["min"] as float, p["max"] as float)
_params[key]["value"] = val
_values[key] = val
func reset_all() -> void:
for key: String in _params:
var default: Variant = _params[key]["default"]
_params[key]["value"] = default
_values[key] = default
any_changed.emit("__all__", null)
## Reset a single param to its default. No-op on unknown key.
func reset(key: String) -> void:
if not _params.has(key):
return
var default: Variant = _params[key]["default"]
_params[key]["value"] = default
_values[key] = default
any_changed.emit(key, default)
## True when the param currently differs from its registered default.
func is_modified(key: String) -> bool:
return _params.has(key) and _params[key]["value"] != _params[key]["default"]
+1
View File
@@ -0,0 +1 @@
uid://b3ix5yi7b3ynh
+59
View File
@@ -0,0 +1,59 @@
# FX — the effect library
Gameplay code fires a **named** effect; the **scene** decides how it looks. That split is the whole
point: a programmer writes `Fx.spawn(&"blood_burst", hit_transform)` once, and from then on every
change to how a hit *feels* is a `.tscn` Siim edits in the inspector — no code, no recompile.
```gdscript
# anywhere in gameplay code
Fx.spawn(&"blood_burst", global_transform) # at a node
Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point
```
## Siim's workflow
1. **Open the gallery.** Run `fx/effect_gallery.tscn`. It loops through every effect on a sand-lit
stage with the effect's name on screen, so you see each one the way it reads in play.
2. **Pick an effect** `.tscn` in `fx/` (`blood_burst`, `slam_dust`, `spark`, `hit_flash`) and open it.
3. **Tune it in the inspector.** Everything lives on the `CPUParticles3D` node and its material:
- **Colour** → the node's *Color Ramp* (start colour → faded/transparent end).
- **Shape of the burst over time** → *Scale Amount Curve*.
- **Amount / Lifetime / Explosiveness / Spread / Velocity / Gravity** → the node's emission group.
- The viewport previews particles live as you drag — no play needed.
4. **Save.** That's the whole edit. Re-run the gallery to compare.
New effect? Add a `fx/<name>.tscn` (root = `CPUParticles3D` with `one_shot_effect.gd` attached — copy
an existing one), then register its name → path in `fx/fx.gd`.
## House style (keep effects reading as one game)
**Palette** — pull from these, don't invent per-effect colours:
| Role | Start | End (fades to alpha 0) |
|-------|---------------------------|-----------------------------|
| Blood | `#FF0505` | `#400000` |
| Dust | `#B89966` (tan) | `#8C7326` |
| Ember | `#FFF299` → `#FF8C1A` | transparent |
| Flash | `#FFFFFF` | transparent |
**Timing budgets** — hits read best short: strike/flash effects ≤ **0.4 s**, dust/settle ≤ **1.1 s**.
A one-shot effect frees itself `lifetime + 0.5 s` after spawn (`one_shot_effect.gd`), so keep
`lifetime` honest.
**PS1 look** — materials are **unshaded**, **alpha-blended**, **vertex-colour** (colour comes from the
ramp), cull disabled. Prefer few, chunky, low-poly billboarded quads over dense fine particles; the
game runs the PS1 filter over everything, so subtlety is lost anyway.
**Mobile** — effects are **`CPUParticles3D`, never `GPUParticles3D`**. The web/GLES3/ANGLE target
compiles particle shaders badly (see the notes in `player.gd`); CPU particles are just as tunable in
the inspector and safe on-device. Keep this constraint when adding effects.
## Files
| File | What it is |
|---|---|
| `fx.gd` | The `Fx` autoload — `spawn(name, xform, parent=null)`; name→path registry |
| `one_shot_effect.gd` | Root helper: fires the emitter(s) once, frees the scene when done |
| `blood_burst.tscn` etc. | The effects themselves — **edit these** |
| `effect_gallery.tscn` | Preview stage (run it to review) |
| `_seed_placeholders.gd` | One-off generator that wrote the first-draft defaults; not used at runtime |
+49
View File
@@ -0,0 +1,49 @@
extends CPUParticles3D
## One-shot blood spray. Add as a child of whatever bleeds, then call burst().
func _ready() -> void:
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 sphere := SphereMesh.new()
sphere.radius = 0.07
sphere.height = 0.14
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.25, 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.5))
scale_curve.add_point(Vector2(1.0, 0.0))
mesh = sphere
color_ramp = ramp
scale_amount_curve = scale_curve
one_shot = true
explosiveness = 0.9
amount = 25
lifetime = 1.1
randomness = 0.4
local_coords = false
spread = 80.0
gravity = Vector3(0.0, -7.0, 0.0)
initial_velocity_min = 4.0
initial_velocity_max = 11.0
scale_amount_min = 0.5
scale_amount_max = 1.6
emitting = false
# Fire a burst at a world position, sprayed along dir (biased upward).
func burst(at: Vector3, dir: Vector3) -> void:
global_position = at
direction = (dir * 0.6 + Vector3.UP * 0.4).normalized()
restart()
+1
View File
@@ -0,0 +1 @@
uid://cnhmhom4w5o3i
+42
View File
@@ -0,0 +1,42 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="1_l68fm"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_okatq"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="SphereMesh_n7uw4"]
material = SubResource("StandardMaterial3D_okatq")
radius = 0.07
height = 0.14
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="Curve_a71nq"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.6, 0.5), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="Gradient_mhqc0"]
colors = PackedColorArray(1, 0.02, 0.02, 1, 0.25, 0, 0, 0)
[node name="blood_burst" type="CPUParticles3D" unique_id=1553421910]
emitting = false
amount = 25
lifetime = 1.1
one_shot = true
explosiveness = 0.9
randomness = 0.4
mesh = SubResource("SphereMesh_n7uw4")
direction = Vector3(0, 1, 0)
spread = 80.0
gravity = Vector3(0, -7, 0)
initial_velocity_min = 4.0
initial_velocity_max = 11.0
scale_amount_min = 0.5
scale_amount_max = 1.6
scale_amount_curve = SubResource("Curve_a71nq")
color_ramp = SubResource("Gradient_mhqc0")
script = ExtResource("1_l68fm")
+30
View File
@@ -0,0 +1,30 @@
extends Node3D
## Live preview stage for the FX library. Cycles through every registered effect on a loop against
## arena-ish lighting so Siim sees each one as it reads in-game. Open fx/effect_gallery.tscn and
## press Play, then edit any effect .tscn in the inspector and re-run to compare. See fx/README.md.
const _CYCLE_SEC := 1.6
const _EFFECTS: Array[StringName] = [&"blood_burst", &"slam_dust", &"spark", &"hit_flash"]
@onready var _label: Label = $UI/Label
# Reached by path rather than the Fx global so the placeholder-seeder can compile and attach this
# script headless (autoload globals aren't resolvable during an on-demand compile in a --script run).
@onready var _fx: Node = get_node("/root/Fx")
var _index := 0
func _ready() -> void:
var timer := Timer.new()
timer.wait_time = _CYCLE_SEC
timer.autostart = true
add_child(timer)
timer.timeout.connect(_fire_next)
_fire_next()
func _fire_next() -> void:
var effect := _EFFECTS[_index % _EFFECTS.size()]
_index += 1
_label.text = "%s (%d/%d)" % [effect, (_index - 1) % _EFFECTS.size() + 1, _EFFECTS.size()]
_fx.call("spawn", effect, global_transform.translated_local(Vector3(0.0, 0.25, 0.0)))
+1
View File
@@ -0,0 +1 @@
uid://bu2r8vtpfpwe7
+32
View File
@@ -0,0 +1,32 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/effect_gallery.gd" id="1_opqwa"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_lt3bb"]
albedo_color = Color(0.62, 0.53, 0.36, 1)
[sub_resource type="PlaneMesh" id="PlaneMesh_2rj6n"]
material = SubResource("StandardMaterial3D_lt3bb")
size = Vector2(12, 12)
[node name="EffectGallery" type="Node3D" unique_id=530454952]
script = ExtResource("1_opqwa")
[node name="Camera3D" type="Camera3D" parent="." unique_id=1818153475]
transform = Transform3D(1, 0, 0, 0, 0.95105654, 0.309017, 0, -0.309017, 0.95105654, 0, 2, 4.5)
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=1218164313]
transform = Transform3D(0.8660254, 0.38302222, -0.32139382, 0, 0.64278764, 0.76604444, 0.5, -0.66341394, 0.5566704, 0, 0, 0)
[node name="Ground" type="MeshInstance3D" parent="." unique_id=178415463]
mesh = SubResource("PlaneMesh_2rj6n")
[node name="UI" type="CanvasLayer" parent="." unique_id=1848100773]
[node name="Label" type="Label" parent="UI" unique_id=1347149836]
offset_left = 24.0
offset_top = 20.0
offset_right = 24.0
offset_bottom = 20.0
theme_override_font_sizes/font_size = 28
text = "FX gallery"
+51
View File
@@ -0,0 +1,51 @@
extends Node
## Fx — the one place gameplay code fires a visual effect. Code names WHICH effect and WHERE;
## the .tscn owns HOW it looks (colour, curves, emission — all inspector-editable, no recompile).
## Autoload singleton. Usage: Fx.spawn(&"blood_burst", hit_transform)
##
## Scenes are loaded lazily (not preloaded) so this autoload compiles and boots even before the
## placeholder .tscn are seeded — see tools/seed_fx_placeholders.gd.
const _PATHS: Dictionary = {
&"blood_burst": "res://systems/fx/blood_burst.tscn",
&"slam_dust": "res://systems/fx/slam_dust.tscn",
&"spark": "res://systems/fx/spark.tscn",
&"hit_flash": "res://systems/fx/hit_flash.tscn",
}
var _cache: Dictionary = {}
# Spawn a named effect at `xform`, parented to `parent` (or the current scene). Returns the
# instance, or null if the effect is unknown or its scene is missing.
func spawn(effect: StringName, xform: Transform3D, parent: Node = null) -> Node:
var scene := _scene(effect)
if scene == null:
return null
var host := parent if parent != null else _default_host()
if host == null:
push_warning("Fx.spawn(%s): no parent and no current scene to attach to" % effect)
return null
var inst := scene.instantiate() as Node3D
host.add_child(inst)
inst.global_transform = xform
return inst
func _scene(effect: StringName) -> PackedScene:
if _cache.has(effect):
return _cache[effect]
if not _PATHS.has(effect):
push_warning("Fx.spawn: unknown effect '%s' (add it to fx/fx.gd)" % effect)
return null
var scene := load(_PATHS[effect]) as PackedScene
if scene == null:
push_warning("Fx.spawn: scene missing for '%s' at %s" % [effect, _PATHS[effect]])
return null
_cache[effect] = scene
return scene
func _default_host() -> Node:
var tree := get_tree()
return tree.current_scene if tree != null else null
+1
View File
@@ -0,0 +1 @@
uid://b223163l8yasu
+41
View File
@@ -0,0 +1,41 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="1_6wb3q"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_n65j7"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="SphereMesh_ih2se"]
material = SubResource("StandardMaterial3D_n65j7")
radius = 0.05
height = 0.1
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="Curve_kfjqk"]
_data = [Vector2(0, 0.2), 0.0, 0.0, 0, 0, Vector2(0.3, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="Gradient_8o2om"]
colors = PackedColorArray(1, 1, 1, 1, 1, 1, 1, 0)
[node name="hit_flash" type="CPUParticles3D" unique_id=454164430]
emitting = false
amount = 6
lifetime = 0.18
one_shot = true
explosiveness = 1.0
randomness = 0.2
mesh = SubResource("SphereMesh_ih2se")
spread = 180.0
gravity = Vector3(0, 0, 0)
initial_velocity_min = 0.5
initial_velocity_max = 1.5
scale_amount_min = 0.8
scale_amount_max = 1.4
scale_amount_curve = SubResource("Curve_kfjqk")
color_ramp = SubResource("Gradient_8o2om")
script = ExtResource("1_6wb3q")
+33
View File
@@ -0,0 +1,33 @@
extends CPUParticles3D
## Root helper for a one-shot effect scene: fires this emitter (and any CPUParticles3D children)
## once when spawned, then frees the whole scene after the longest-lived particle has died. This
## is what lets an effect be nothing but a tunable CPUParticles3D scene — no per-effect code, so
## Siim owns the look entirely in the inspector. Fired by the Fx autoload; see fx/README.md.
const _FREE_MARGIN := 0.5
func _ready() -> void:
var ttl := _arm(self)
for emitter: CPUParticles3D in _child_emitters():
ttl = maxf(ttl, _arm(emitter))
get_tree().create_timer(ttl + _FREE_MARGIN).timeout.connect(queue_free)
func _arm(p: CPUParticles3D) -> float:
p.one_shot = true
p.emitting = true
return p.lifetime
func _child_emitters() -> Array[CPUParticles3D]:
var out: Array[CPUParticles3D] = []
_gather(self, out)
return out
func _gather(node: Node, out: Array[CPUParticles3D]) -> void:
for child: Node in node.get_children():
if child is CPUParticles3D:
out.append(child)
_gather(child, out)
+1
View File
@@ -0,0 +1 @@
uid://dkoudbnrkfiaw
+44
View File
@@ -0,0 +1,44 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="1_88uyl"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_1tr47"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="SphereMesh_bw8nk"]
material = SubResource("StandardMaterial3D_1tr47")
radius = 0.16
height = 0.32
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="Curve_xgm72"]
_data = [Vector2(0, 0.4), 0.0, 0.0, 0, 0, Vector2(0.35, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="Gradient_l8m5g"]
colors = PackedColorArray(0.72, 0.6, 0.4, 0.9, 0.55, 0.45, 0.3, 0)
[node name="slam_dust" type="CPUParticles3D" unique_id=82833428]
emitting = false
amount = 36
lifetime = 0.9
one_shot = true
explosiveness = 1.0
randomness = 0.5
mesh = SubResource("SphereMesh_bw8nk")
emission_shape = 1
emission_sphere_radius = 0.5
direction = Vector3(0, 1, 0)
spread = 75.0
gravity = Vector3(0, -6, 0)
initial_velocity_min = 2.5
initial_velocity_max = 6.5
scale_amount_min = 0.6
scale_amount_max = 1.4
scale_amount_curve = SubResource("Curve_xgm72")
color_ramp = SubResource("Gradient_l8m5g")
script = ExtResource("1_88uyl")
+40
View File
@@ -0,0 +1,40 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="1_kk3a7"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_tjifq"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="SphereMesh_pv2r6"]
material = SubResource("StandardMaterial3D_tjifq")
radius = 0.03
height = 0.06
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="Curve_l33bb"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 2
[sub_resource type="Gradient" id="Gradient_pnkyn"]
colors = PackedColorArray(1, 0.95, 0.6, 1, 1, 0.55, 0.1, 0)
[node name="spark" type="CPUParticles3D" unique_id=69954641]
emitting = false
amount = 14
lifetime = 0.35
one_shot = true
explosiveness = 1.0
randomness = 0.6
mesh = SubResource("SphereMesh_pv2r6")
direction = Vector3(0, 1, 0)
gravity = Vector3(0, -9, 0)
initial_velocity_min = 3.0
initial_velocity_max = 7.0
scale_amount_min = 0.4
scale_amount_curve = SubResource("Curve_l33bb")
color_ramp = SubResource("Gradient_pnkyn")
script = ExtResource("1_kk3a7")
+290
View File
@@ -0,0 +1,290 @@
extends Node
## Global keybinding manager (autoload `Controls`).
## Loads saved keyboard bindings on startup and applies them to InputMap so
## rebinds made in the intro menu persist into the game scene. Only the
## keyboard event of each action is overridden; joypad/mouse events are kept.
const SAVE_PATH := "user://controls.cfg"
const SECTION := "keybinds"
## Actions the options menu lets the player rebind (keyboard only).
const REBINDABLE_ACTIONS: PackedStringArray = [
&"move_forward",
&"move_back",
&"move_left",
&"move_right",
&"ability_kick",
&"ability_dash",
&"ability_slam",
&"ability_roll",
]
func _ready() -> void:
load_saved()
_install_web_fullscreen_hook()
# ── Web fullscreen + landscape lock ─────────────────────────────────────────────
# The reliable way to force fullscreen on the web: install a NATIVE DOM listener that calls
# requestFullscreen() synchronously inside the real user-gesture event — the same thing
# three.js games do. Godot dispatches input from its rAF render loop, one hop removed from the
# DOM event, so both Godot's window_set_mode and a JS call made from _input() run outside the
# gesture's activation and some mobile browsers reject them. The listener below sidesteps that
# entirely, and — left attached — re-enters fullscreen on the next tap if the player leaves it.
# All of it is guarded, so iPhone Safari (no requestFullscreen) and desktop just no-op cleanly.
func _install_web_fullscreen_hook() -> void:
if not OS.has_feature("web"):
return
JavaScriptBridge.eval("""
(function(){
if (window.__bullFSInit) return;
window.__bullFSInit = true;
window.__bull_fs = 'idle';
// Armed = we should grab fullscreen on the next tap. Starts true so the first tap enters,
// then disarms — so once the player is in (or deliberately leaves via Esc / back / swipe)
// ordinary gameplay taps never yank them back in. WebStartGate re-arms via arm_fullscreen().
window.__bullFSArmed = true;
window.__bullFS = function(){
try {
if (document.fullscreenElement || document.webkitFullscreenElement) {
window.__bull_fs = 'ok'; window.__bullFSArmed = false; return;
}
if (!window.__bullFSArmed) return;
var c = document.getElementById('canvas') || document.querySelector('canvas')
|| document.documentElement;
var rf = c.requestFullscreen || c.webkitRequestFullscreen
|| c.msRequestFullscreen || c.mozRequestFullScreen;
if (!rf) { window.__bull_fs = 'no-api'; lock(); return; }
window.__bull_fs = 'req';
var p = rf.call(c);
if (p && p.then) {
p.then(function(){ window.__bull_fs = 'ok'; window.__bullFSArmed = false; lock(); },
function(e){ window.__bull_fs = 'err:' + (e && e.name || e); });
} else { window.__bull_fs = 'ok'; window.__bullFSArmed = false; lock(); }
} catch(e){ window.__bull_fs = 'ex:' + (e && e.name || e); }
function lock(){ try { if (screen.orientation && screen.orientation.lock)
screen.orientation.lock('landscape').catch(function(){}); } catch(e){} }
};
// pointerup / touchend / click grant transient activation (pointerdown/touchstart do not).
['pointerup','touchend','click'].forEach(function(ev){
window.addEventListener(ev, function(){ window.__bullFS(); }, true);
});
})();
""", true)
## Ask to enter fullscreen + landscape now (e.g. from WebStartGate's tap). The DOM listener
## installed above normally beats this to it on the same gesture; this is the explicit path and
## is a safe no-op if we're already fullscreen. No-op off web.
func request_fullscreen_landscape() -> void:
if not OS.has_feature("web"):
return
_install_web_fullscreen_hook()
JavaScriptBridge.eval("if (window.__bullFS) window.__bullFS();", true)
## Re-arm the "grab fullscreen on next tap" hook, so the next tap re-enters. WebStartGate calls
## this whenever it shows itself (boot, or after the player left fullscreen). No-op off web.
func arm_fullscreen() -> void:
if OS.has_feature("web"):
JavaScriptBridge.eval("window.__bullFSArmed = true;", true)
## Whether the browser can go fullscreen at all. False on iPhone Safari (no requestFullscreen),
## which lets WebStartGate stop prompting there instead of nagging forever. True off web (n/a).
func fullscreen_supported() -> bool:
if not OS.has_feature("web"):
return true
var r: Variant = JavaScriptBridge.eval(
"(function(){var c=document.getElementById('canvas')||document.documentElement;"
+ "return !!(c.requestFullscreen||c.webkitRequestFullscreen"
+ "||c.msRequestFullscreen||c.mozRequestFullScreen)?1:0;})()", true)
if r is bool:
return r
if r is int or r is float:
return int(r) != 0
return false
## Is the browser currently fullscreen? No-op-ish off web (returns false).
func is_browser_fullscreen() -> bool:
if not OS.has_feature("web"):
return false
var r: Variant = JavaScriptBridge.eval(
"(document.fullscreenElement||document.webkitFullscreenElement)?1:0", true)
if r is bool:
return r
if r is int or r is float:
return int(r) != 0
return false
## One-line web diagnostic for the on-device debug overlay: secure context, cross-origin
## isolation (threads), last fullscreen attempt result, and whether we're fullscreen now.
func fullscreen_status() -> String:
if not OS.has_feature("web"):
return "native"
var r: Variant = JavaScriptBridge.eval(
"'sec=' + (window.isSecureContext ? 1 : 0) + ' iso=' + (window.crossOriginIsolated ? 1 : 0)"
+ " + ' fs=' + (window.__bull_fs || '?')"
+ " + ' cur=' + ((document.fullscreenElement || document.webkitFullscreenElement) ? 1 : 0)",
true)
return str(r) if r != null else "?"
## Whether to drive the game with the on-screen touch UI (joystick + buttons)
## instead of keyboard hints. Platform detection is cached (it can't change mid-run)
## so the per-frame/per-input callers don't repay the cost — but the DP force flag is
## re-read every call so the console can toggle the layout live on a desktop build.
var _touch_ui_cached: int = -1 # -1 = not yet detected, 0 = false, 1 = true
func use_touch_ui() -> bool:
if DP.b("force_touch_controls"):
return true
if _touch_ui_cached == -1:
_touch_ui_cached = 1 if _detect_touch_ui() else 0
return _touch_ui_cached == 1
## True only on touch-primary devices (phones, tablets). Detection is deliberately
## conservative so a desktop never gets the on-screen thumb UI.
##
## DisplayServer.is_touchscreen_available() is unusable here on two fronts: on native
## desktop `emulate_touch_from_mouse` (on for testing) forces it true, and on the web build
## it maps to `'ontouchstart' in window`, which desktop Chromium reports true even with no
## touchscreen. So on web we ask the browser two questions instead: does it report touch
## points, AND is the *primary* pointer coarse? A desktop-with-mouse (even a touch laptop)
## answers no to the second and keeps the desktop HUD; a phone/tablet answers yes to both.
## maxTouchPoints also catches iPadOS, which masquerades as desktop Safari.
func _detect_touch_ui() -> bool:
if (
OS.has_feature("mobile")
or OS.has_feature("web_android")
or OS.has_feature("web_ios")
):
return true
if not OS.has_feature("web"):
return false
var js := "(navigator.maxTouchPoints > 0) && window.matchMedia('(pointer: coarse)').matches"
var touch_primary: Variant = JavaScriptBridge.eval(js, true)
if touch_primary is bool:
return touch_primary
if touch_primary is int or touch_primary is float:
return int(touch_primary) != 0
return false
## Whether on-screen debug overlays (touch state + render diagnostics) should show. True on
## the DP "touch_debug" flag, or — so it can be switched on from a phone where no console is
## reachable — when the page URL carries a `debug` query param (append `?debug=1`).
var _url_debug: int = -1
func debug_overlay() -> bool:
if DP.b("touch_debug"):
return true
if _url_debug == -1:
_url_debug = 0
if OS.has_feature("web"):
var has: Variant = JavaScriptBridge.eval(
"String(window.location.search).indexOf('debug') >= 0", true)
if (has is bool and has) or ((has is int or has is float) and int(has) != 0):
_url_debug = 1
return _url_debug == 1
## Whether to apply the rigid-skin workaround (rigid_skin.gd) instead of native GPU skinning.
## Defaults to the DP "use_rigid_skin" flag (on for web), but a page URL param overrides it so
## native skinning can be A/B'd on the actual phone with no console reachable: append `?noskin=1`
## to force it OFF (does the device render skinned meshes natively now — e.g. after a Godot web
## template update? — and do the bear's rigid-skin seam holes go away), or `?skin=1` to force ON.
## -1 = not read yet, 0 = url forces off, 1 = url forces on, 2 = no url override (use DP flag).
var _url_skin: int = -1
func rigid_skin_enabled() -> bool:
if _url_skin == -1:
_url_skin = 2
if OS.has_feature("web"):
var q: Variant = JavaScriptBridge.eval("String(window.location.search)", true)
var s: String = str(q) if q != null else ""
if s.find("noskin") >= 0:
_url_skin = 0
elif s.find("skin") >= 0:
_url_skin = 1
if _url_skin == 0:
return false
if _url_skin == 1:
return true
return DP.b("use_rigid_skin")
## Returns the first keyboard event bound to an action, or null.
func get_key_event(action: StringName) -> InputEventKey:
for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventKey:
return event
return null
## Human-readable label for the action's current key (e.g. "W", "Space").
func get_key_label(action: StringName) -> String:
var event := get_key_event(action)
if event == null:
return "—"
return event.as_text_physical_keycode()
## Replaces the primary keyboard event of `action` with `event`, keeping
## every other event (e.g. arrow-key alternates, joypad) in its place.
func rebind(action: StringName, event: InputEventKey) -> void:
rebind_no_save(action, event)
save()
func save() -> void:
var cfg := ConfigFile.new()
for action: StringName in REBINDABLE_ACTIONS:
var event := get_key_event(action)
if event != null:
cfg.set_value(SECTION, String(action), event.physical_keycode)
cfg.save(SAVE_PATH)
func load_saved() -> void:
var cfg := ConfigFile.new()
if cfg.load(SAVE_PATH) != OK:
return
for action: StringName in REBINDABLE_ACTIONS:
var key := String(action)
if not cfg.has_section_key(SECTION, key):
continue
var keycode: int = cfg.get_value(SECTION, key)
var event := InputEventKey.new()
event.physical_keycode = keycode as Key
rebind_no_save(action, event)
## Rebuilds the action's event list, swapping the first keyboard event for
## `event` in place so ordering (and thus the "primary" key) is preserved.
func rebind_no_save(action: StringName, event: InputEventKey) -> void:
var events := InputMap.action_get_events(action)
InputMap.action_erase_events(action)
var replaced := false
for existing: InputEvent in events:
if not replaced and existing is InputEventKey:
InputMap.action_add_event(action, event)
replaced = true
else:
InputMap.action_add_event(action, existing)
if not replaced:
InputMap.action_add_event(action, event)
func reset_defaults() -> void:
InputMap.load_from_project_settings()
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)
+1
View File
@@ -0,0 +1 @@
uid://dvxx75l5re2mf
+656
View File
@@ -0,0 +1,656 @@
extends CanvasLayer
## On-screen controls for touch devices: a floating movement joystick on the left
## and a cluster of ability buttons on the right. Instantiated by hud.gd.
##
## All interaction is driven from raw `_input()` with manual hit-testing, NOT from
## Control.gui_input. On the web/wasm build the overlay lives in a CanvasLayer nested
## under the HUD, and GUI touch routing into a nested CanvasLayer renders fine but
## silently drops input — a standard Button here never fires. `_input()` sees every
## InputEventScreenTouch regardless of nesting, mouse_filter, or emulate_mouse_from_touch,
## so the joystick and buttons work reliably across browsers. Everything is drawn in one
## Control (_surface) whose rects are the single source of truth for both drawing and
## hit-testing.
##
## The joystick feeds the same `move_*` input actions the keyboard/gamepad drive (via
## Input.action_press with an analog strength), so player.gd needs no touch awareness — it
## just reads Input.get_vector() as always. Ability buttons call the player's public
## try_activate_ability(), sharing the keyboard's cooldown gate.
##
## Shown only on touch platforms (see Controls.use_touch_ui), or when DP
## "force_touch_controls" is on so the layout can be exercised on a desktop build
## (emulate_touch_from_mouse turns a mouse click into a touch event for that).
# move_* actions, indexed by the four joystick push directions.
const _MOVE_RIGHT: StringName = &"move_right"
const _MOVE_LEFT: StringName = &"move_left"
const _MOVE_BACK: StringName = &"move_back"
const _MOVE_FORWARD: StringName = &"move_forward"
# Ability slots in player.gd order: kick=0, dash=1, slam=2, roll=3.
const _ABILITY_ICONS: Array[String] = [
"res://HUD/KICK.png",
"res://HUD/DASH.png",
"res://HUD/SLAM.png",
"res://HUD/ROLL.png",
]
const _ABILITY_LETTERS: Array[String] = ["K", "D", "S", "R"]
const _ABILITY_ACCENT: Array[Color] = [
Color(1.00, 0.62, 0.16), # Kick — ember orange
Color(0.45, 0.75, 1.00), # Dash — sky blue
Color(0.98, 0.34, 0.20), # Slam — crimson
Color(1.00, 0.82, 0.32), # Roll — gold
]
# Sentinel touch indices. A real finger uses its own non-negative index; the mouse
# (desktop force-flag testing, when no real touch has been seen) uses _MOUSE.
const _MOUSE: int = -2
var _surface: Control
var _player: Node = null
# Cached textures + styleboxes so the per-frame redraw (cooldowns animate) allocates nothing.
var _ability_tex: Array[Texture2D] = []
var _ability_style: Array[StyleBoxFlat] = []
var _menu_style: StyleBoxFlat
# Layout rects in screen space — the single source of truth for drawing AND hit-testing.
# Ability buttons are hit-tested against a rect grown by _ability_hit_pad (fills the gaps
# between them) so a near-miss still fires — the visual box stays the smaller drawn rect.
# _slot_pressed drives a brief press highlight so a tap reads as registered.
var _stick_zone: Rect2 = Rect2()
var _ability_rects: Array[Rect2] = []
var _ability_hit_pad: float = 0.0
var _slot_pressed: Array[bool] = [false, false, false, false]
var _menu_rect: Rect2 = Rect2()
# Resting "drag to move" affordance drawn in the lower-left when the stick is idle, so a
# first-time player sees where to plant their thumb instead of guessing. _hint_pulse gives
# it a slow breathing alpha for discoverability.
var _stick_hint_center: Vector2 = Vector2()
var _hint_pulse: float = 0.0
# Floating-joystick state. _origin is where the thumb first touched (base centre), _knob is
# the current thumb position; both in screen coordinates. _stick_index is the finger driving
# the stick (-1 = none): keying release/drag off it (not just the owner dict) lets the latest
# left-zone touch always re-acquire the stick, so a stranded stick — e.g. a touchend lost to a
# resize — self-heals the moment the player touches down again.
var _stick_active: bool = false
var _stick_index: int = -1
var _origin: Vector2 = Vector2.ZERO
var _knob: Vector2 = Vector2.ZERO
var _stick_radius: float = 110.0
# Live fingers by index → screen position, plus what each finger is doing ("stick", a slot
# int, "menu", or "camera"). Ownership keeps a second finger on a button from stealing the
# stick, and keeps steering/ability fingers from being mistaken for a pinch gesture.
var _touches: Dictionary = {}
var _touch_owner: Dictionary = {}
var _saw_real_touch: bool = false
# Pinch-to-zoom: only "camera" fingers (right side, not on any button) count, so steering
# with one thumb while tapping an ability with the other never reads as a zoom.
var _pinching: bool = false
var _pinch_dist: float = 0.0
# Diagnostics surfaced by DP "touch_debug" — lets us see, on the actual device, whether
# events arrive and where, since no console is reachable there.
var _dbg_events: int = 0
var _dbg_last_pos: Vector2 = Vector2.ZERO
# Profiler peak-hold (debug overlay). Godot's game logic — every _process / _physics_process —
# runs on ONE thread on web regardless of thread_support, so a lag spike shows up as a jump in
# process(script) or physics(ragdoll) ms, NOT in FPS alone. We hold the worst value seen in the
# last _PEAK_HOLD_MS so an ability-press spike stays readable instead of flashing by in a frame.
# JS-backed fields (fullscreen status, cross-origin isolation) are cached so the overlay's own
# per-frame JavaScriptBridge.eval doesn't inflate the very process time it's trying to measure.
const _PEAK_HOLD_MS: float = 2000.0
var _peak_proc_ms: float = 0.0
var _peak_phys_ms: float = 0.0
var _peak_proc_t: int = 0
var _peak_phys_t: int = 0
var _iso_cached: int = -1
var _fs_cache: String = "?"
var _fs_cache_t: int = 0
# Cached real GPU name. RenderingServer.get_video_adapter_name() returns the browser's
# privacy-masked "WebKit WebGL" on the web; the true chip (Adreno/Mali/…) — which decides
# whether an invisible skinned mesh is a known mobile-driver bug — is only reachable via
# the WEBGL_debug_renderer_info extension on Godot's own canvas context. "" = not yet probed.
var _gpu_unmasked: String = ""
func _unmasked_gpu() -> String:
if _gpu_unmasked != "":
return _gpu_unmasked
_gpu_unmasked = "n/a"
if OS.has_feature("web"):
var js := """(function(){try{
var c=document.getElementById('canvas');
var gl=c?c.getContext('webgl2'):null;
if(!gl){var t=document.createElement('canvas');gl=t.getContext('webgl2')||t.getContext('webgl');}
if(!gl)return 'no-gl';
var e=gl.getExtension('WEBGL_debug_renderer_info');
if(!e)return 'masked';
return String(gl.getParameter(e.UNMASKED_RENDERER_WEBGL));
}catch(err){return 'err';}})()"""
var r: Variant = JavaScriptBridge.eval(js, true)
if r is String and (r as String) != "":
_gpu_unmasked = r
return _gpu_unmasked
func _ready() -> void:
layer = 10 # below the HUD (11) so the game-over screen always draws on top
process_mode = Node.PROCESS_MODE_ALWAYS
for slot: int in 4:
var tex: Texture2D = null
if ResourceLoader.exists(_ABILITY_ICONS[slot]):
tex = load(_ABILITY_ICONS[slot])
_ability_tex.append(tex)
_ability_style.append(_button_style(_ABILITY_ACCENT[slot]))
_menu_style = _button_style(Color(1.00, 0.78, 0.22, 0.8))
_surface = Control.new()
_surface.set_anchors_preset(Control.PRESET_FULL_RECT)
_surface.mouse_filter = Control.MOUSE_FILTER_IGNORE
_surface.visible = false
_surface.draw.connect(_draw_surface)
add_child(_surface)
_relayout()
get_viewport().size_changed.connect(_relayout)
# ── Layout ────────────────────────────────────────────────────────────────────
# Left half is the joystick zone (touch anywhere in it to plant the base under the thumb);
# the ability cluster and menu button sit in the right half so they never overlap it.
func _relayout() -> void:
# A resize / orientation flip invalidates every rect and can swallow an in-flight touchend
# (the browser drops it mid-transition), which is exactly what used to strand the joystick.
# Clearing touch state here means the layout always comes back to a clean slate.
_reset_touch_state()
var vp := get_viewport().get_visible_rect().size
var short := minf(vp.x, vp.y)
_stick_radius = clampf(short * 0.16, 80.0, 150.0)
_stick_zone = Rect2(Vector2.ZERO, Vector2(vp.x * 0.5, vp.y))
var bs := clampf(short * 0.22, 108.0, 152.0) # ability button edge — bigger, easier thumb target
var gap := bs * 0.22
var margin := bs * 0.34
_ability_hit_pad = gap * 0.5 # grow hit rects to meet in the gaps; near-misses still register
var right := vp.x - margin
var bottom := vp.y - margin
var col0 := right - bs * 2.0 - gap
var col1 := right - bs
var row0 := bottom - bs * 2.0 - gap
var row1 := bottom - bs
_ability_rects = [
Rect2(col0, row0, bs, bs), # kick
Rect2(col1, row0, bs, bs), # dash
Rect2(col0, row1, bs, bs), # slam
Rect2(col1, row1, bs, bs), # roll
]
var ms := clampf(short * 0.11, 60.0, 84.0) # bigger menu button
var mm := ms * 0.34 # and pulled further off the corner
_menu_rect = Rect2(vp.x - ms - mm, mm, ms, ms)
_stick_hint_center = Vector2(vp.x * 0.20, vp.y * 0.74)
_surface.queue_redraw()
# ── Input ───────────────────────────────────────────────────────────────────
# Raw events only. Real touches drive the touch path; once any real touch is seen the
# mouse path is disabled (emulate_mouse_from_touch would otherwise double-fire every tap).
# The mouse path stays live for desktop force-flag testing where no touchscreen exists.
func _input(event: InputEvent) -> void:
if get_tree().paused or not Controls.use_touch_ui():
return
if event is InputEventScreenTouch:
_saw_real_touch = true
var t := event as InputEventScreenTouch
_dbg_events += 1
_dbg_last_pos = t.position
if t.pressed:
_touches[t.index] = t.position
_on_press(t.index, t.position)
else:
_on_release(t.index, t.position)
_touches.erase(t.index)
_update_pinch_state()
_surface.queue_redraw()
get_viewport().set_input_as_handled()
elif event is InputEventScreenDrag:
var d := event as InputEventScreenDrag
_dbg_events += 1
_dbg_last_pos = d.position
_touches[d.index] = d.position
_on_drag(d.index, d.position)
_surface.queue_redraw()
get_viewport().set_input_as_handled()
elif not _saw_real_touch and event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT:
_dbg_events += 1
_dbg_last_pos = mb.position
if mb.pressed:
_touches[_MOUSE] = mb.position
_on_press(_MOUSE, mb.position)
else:
_on_release(_MOUSE, mb.position)
_touches.erase(_MOUSE)
_update_pinch_state()
_surface.queue_redraw()
elif not _saw_real_touch and event is InputEventMouseMotion:
if _touch_owner.get(_MOUSE, null) == "stick":
_touches[_MOUSE] = (event as InputEventMouseMotion).position
_update_stick(_touches[_MOUSE])
_surface.queue_redraw()
func _on_press(index: int, pos: Vector2) -> void:
if _menu_rect.has_point(pos):
_touch_owner[index] = "menu"
return
for slot: int in _ability_rects.size():
if _ability_rects[slot].grow(_ability_hit_pad).has_point(pos):
_touch_owner[index] = slot
_slot_pressed[slot] = true
if _player != null:
_player.call(&"try_activate_ability", slot)
return
if _stick_zone.has_point(pos):
# Last touch in the zone (re)acquires the stick — this is what recovers a stranded stick
# whose finger's release was never delivered. A prior owner is superseded via _stick_index.
_touch_owner[index] = "stick"
_begin_stick(index, pos)
return
_touch_owner[index] = "camera"
func _on_drag(index: int, pos: Vector2) -> void:
match _touch_owner.get(index, null):
"stick":
if index == _stick_index:
_update_stick(pos)
"camera":
if _pinching:
_apply_pinch()
func _on_release(index: int, pos: Vector2) -> void:
var owner: Variant = _touch_owner.get(index, null)
match owner:
"stick":
# Only the finger that currently owns the stick ends it; a superseded finger
# (another thumb took over) just drops its own mapping below.
if index == _stick_index:
_end_stick()
"menu":
if _menu_rect.has_point(pos):
_touch_owner.erase(index)
_return_to_menu()
return
if owner is int:
_slot_pressed[owner] = false
_touch_owner.erase(index)
# ── Joystick ────────────────────────────────────────────────────────────────
func _begin_stick(index: int, pos: Vector2) -> void:
_stick_active = true
_stick_index = index
_origin = pos
_knob = pos
func _update_stick(pos: Vector2) -> void:
if not _stick_active:
return
var offset := pos - _origin
if offset.length() > _stick_radius:
offset = offset.normalized() * _stick_radius
_knob = _origin + offset
_apply_move(offset / _stick_radius)
func _end_stick() -> void:
_stick_active = false
_stick_index = -1
_release_move()
# Translate a normalised stick offset (screen space: +x right, +y down) into analog
# strengths on the four movement actions. Opposite pairs are mutually exclusive, so only
# one of each axis is ever pressed at a time.
func _apply_move(v: Vector2) -> void:
Input.action_press(_MOVE_RIGHT, maxf(0.0, v.x))
Input.action_press(_MOVE_LEFT, maxf(0.0, -v.x))
Input.action_press(_MOVE_BACK, maxf(0.0, v.y))
Input.action_press(_MOVE_FORWARD, maxf(0.0, -v.y))
func _release_move() -> void:
for action: StringName in [_MOVE_RIGHT, _MOVE_LEFT, _MOVE_BACK, _MOVE_FORWARD]:
Input.action_release(action)
# ── Pinch-to-zoom ─────────────────────────────────────────────────────────────
# Only "camera" fingers (open right-side area, not on a button) participate, so it can't
# fire while the player is steering or tapping abilities.
func _camera_touches() -> Array:
var pts: Array = []
for idx: int in _touches:
if _touch_owner.get(idx, null) == "camera":
pts.append(_touches[idx])
return pts
func _update_pinch_state() -> void:
var pts := _camera_touches()
var was := _pinching
_pinching = pts.size() >= 2
if _pinching and not was:
if _stick_active:
_end_stick()
_pinch_dist = (pts[0] as Vector2).distance_to(pts[1] as Vector2)
elif was and not _pinching:
_pinch_dist = 0.0
func _apply_pinch() -> void:
var pts := _camera_touches()
if pts.size() < 2:
return
var dist := (pts[0] as Vector2).distance_to(pts[1] as Vector2)
if _pinch_dist <= 0.0 or dist <= 0.0:
_pinch_dist = dist
return
var cam := get_viewport().get_camera_3d()
if cam != null and cam.has_method(&"adjust_zoom"):
cam.call(&"adjust_zoom", dist / _pinch_dist)
_pinch_dist = dist
# ── Drawing ───────────────────────────────────────────────────────────────────
func _draw_surface() -> void:
for slot: int in _ability_rects.size():
var r: Rect2 = _ability_rects[slot]
_surface.draw_style_box(_ability_style[slot], r)
if _slot_pressed[slot]:
var glow := _ABILITY_ACCENT[slot]
glow.a = 0.28
_surface.draw_rect(r.grow(-r.size.x * 0.10), glow) # press flash so a tap reads as registered
var inner := r.grow(-r.size.x * 0.06) # small inset so the icon nearly fills the button
if _ability_tex[slot] != null:
_draw_icon_fit(_ability_tex[slot], inner)
else:
_draw_centered_text(_ABILITY_LETTERS[slot], r, 40, _ABILITY_ACCENT[slot])
var frac := 0.0
if _player != null:
frac = clampf(_player.call(&"ability_cooldown_fraction", slot), 0.0, 1.0)
if frac > 0.0:
var ch := r.size.y * frac
_surface.draw_rect(
Rect2(r.position.x, r.position.y + r.size.y - ch, r.size.x, ch),
Color(0.0, 0.0, 0.0, 0.55))
_surface.draw_style_box(_menu_style, _menu_rect)
_draw_hamburger(_menu_rect)
if _stick_active:
_surface.draw_circle(_origin, _stick_radius, Color(0.0, 0.0, 0.0, 0.20))
_surface.draw_arc(
_origin, _stick_radius, 0.0, TAU, 48, Color(0.93, 0.88, 0.72, 0.30), 4.0, true)
_surface.draw_circle(_knob, _stick_radius * 0.42, Color(1.00, 0.82, 0.32, 0.55))
elif not _pinching:
_draw_stick_hint()
if Controls.debug_overlay():
_draw_debug()
# A faint "ghost" joystick at rest in the lower-left with a caption, so the movement zone
# is discoverable. Breathes gently via _hint_pulse; vanishes the moment the stick is grabbed.
func _draw_stick_hint() -> void:
var a := 0.16 + 0.12 * (0.5 + 0.5 * sin(_hint_pulse))
var c := _stick_hint_center
var r := _stick_radius * 0.72
_surface.draw_circle(c, r, Color(0.0, 0.0, 0.0, a * 0.45))
_surface.draw_arc(c, r, 0.0, TAU, 40, Color(0.93, 0.88, 0.72, a + 0.14), 3.0, true)
_surface.draw_circle(c, r * 0.32, Color(1.00, 0.82, 0.32, a + 0.16))
var font := ThemeDB.fallback_font
if font != null:
var txt := "DRAG TO MOVE"
var fs := 18
var tw := font.get_string_size(txt, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
_surface.draw_string(
font, c + Vector2(-tw.x * 0.5, r + 28.0), txt, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
Color(0.93, 0.88, 0.72, a + 0.30))
func _draw_icon_fit(tex: Texture2D, box: Rect2) -> void:
var ts := tex.get_size()
if ts.x <= 0.0 or ts.y <= 0.0:
return
var scale := minf(box.size.x / ts.x, box.size.y / ts.y)
var ds := ts * scale
_surface.draw_texture_rect(tex, Rect2(box.position + (box.size - ds) * 0.5, ds), false)
func _draw_centered_text(text: String, box: Rect2, size: int, color: Color) -> void:
var font := ThemeDB.fallback_font
if font == null:
return
var tw := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, size)
var pos := box.position + (box.size - tw) * 0.5 + Vector2(0.0, tw.y * 0.35)
_surface.draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, size, color)
# Three stacked bars — a "☰" glyph drawn by hand, since the fallback font that ships with
# the web build has no U+2630 and renders it as tofu / a codepoint number.
func _draw_hamburger(r: Rect2) -> void:
var col := Color(1.00, 0.78, 0.22)
var bw := r.size.x * 0.5
var bh := maxf(3.0, r.size.y * 0.09)
var x := r.position.x + (r.size.x - bw) * 0.5
var cy := r.position.y + r.size.y * 0.5
var gap := r.size.y * 0.22
for i: int in [-1, 0, 1]:
_surface.draw_rect(Rect2(x, cy + i * gap - bh * 0.5, bw, bh), col)
# Live on-device profiler. Reads Godot's Performance monitors so we can see WHICH stage is slow
# during a lag spike instead of guessing: process(script) is main-thread GDScript, physics is the
# ragdoll/Jolt step, and low-both-but-low-FPS means the GPU (fill rate / resolution) is the wall.
# The verdict line names the bottleneck. Enable on the phone with ?debug=1 in the page URL.
func _draw_debug() -> void:
var font := ThemeDB.fallback_font
if font == null:
return
var fps: float = Performance.get_monitor(Performance.TIME_FPS)
var proc_ms: float = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
var phys_ms: float = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
var draws: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
var objs: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME))
var prims: int = int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
var node_n: int = int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
var bodies: int = int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
var vmem: float = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0
var now := Time.get_ticks_msec()
if proc_ms >= _peak_proc_ms or now - _peak_proc_t > _PEAK_HOLD_MS:
_peak_proc_ms = proc_ms
_peak_proc_t = now
if phys_ms >= _peak_phys_ms or now - _peak_phys_t > _PEAK_HOLD_MS:
_peak_phys_ms = phys_ms
_peak_phys_t = now
var mats := get_tree().get_nodes_in_group(&"matador")
var rt := get_viewport().get_texture()
var res: Vector2i = rt.get_size() if rt != null else Vector2i.ZERO
var lines := [
"FPS %d frame %.1fms >> %s" % [
int(round(fps)), 1000.0 / maxf(fps, 1.0), _perf_verdict(fps, proc_ms, phys_ms)],
"process(script) %5.1fms peak %.1f" % [proc_ms, _peak_proc_ms],
"physics(ragdoll) %5.1fms peak %.1f bodies %d" % [phys_ms, _peak_phys_ms, bodies],
"draws %d objs %d prims %s" % [draws, objs, _kfmt(prims)],
"nodes %d matadors %d vmem %.0fMB" % [node_n, mats.size(), vmem],
"render %dx%d threads(iso)=%s" % [res.x, res.y, _iso_str()],
"chip: %s fs: %s" % [_unmasked_gpu(), _fs_status_throttled()],
]
var fsz := 18
var line_h := 24.0
var w := 0.0
for line: String in lines:
w = maxf(w, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz).x)
var top := clampf(get_viewport().get_visible_rect().size.y * 0.28, 96.0, 220.0)
_surface.draw_rect(
Rect2(10.0, top, w + 16.0, line_h * lines.size() + 12.0), Color(0.0, 0.0, 0.0, 0.72))
var y := top + 22.0
for line: String in lines:
_surface.draw_string(
font, Vector2(18.0, y), line, HORIZONTAL_ALIGNMENT_LEFT, -1, fsz, Color(1.0, 1.0, 0.35))
y += line_h
# Names the current bottleneck from the frame breakdown. Directly tests the "CPU bound to one
# thread" hypothesis: if process or physics ms is high, that's the single main thread saturated
# and threads can't help it; if both are low while FPS is low, the GPU (fill rate) is the wall.
func _perf_verdict(fps: float, proc_ms: float, phys_ms: float) -> String:
if fps >= 55.0:
return "smooth"
if phys_ms >= 6.0 and phys_ms >= proc_ms:
return "PHYSICS-bound (ragdolls, 1 thread)"
if proc_ms >= 6.0:
return "SCRIPT-bound (GDScript, 1 thread)"
if proc_ms + phys_ms < 5.0:
return "GPU/fill-bound (resolution/shaders)"
return "mixed CPU"
func _kfmt(n: int) -> String:
if n >= 1000000:
return "%.1fM" % (n / 1000000.0)
if n >= 1000:
return "%.0fk" % (n / 1000.0)
return str(n)
# crossOriginIsolated: whether SharedArrayBuffer/threads are even permitted (needs COOP+COEP). Even
# when true, Godot's per-frame game logic stays single-threaded — threads only aid render/audio/load.
func _iso_str() -> String:
if not OS.has_feature("web"):
return "native"
if _iso_cached == -1:
var r: Variant = JavaScriptBridge.eval("window.crossOriginIsolated?1:0", true)
_iso_cached = 1 if ((r is bool and r) or ((r is int or r is float) and int(r) != 0)) else 0
return str(_iso_cached)
# fullscreen_status() does a JavaScriptBridge.eval; throttle it so the overlay isn't paying that
# cost (and skewing the process-ms it reports) every single frame.
func _fs_status_throttled() -> String:
var now := Time.get_ticks_msec()
if now - _fs_cache_t > 500 or _fs_cache == "?":
_fs_cache = Controls.fullscreen_status()
_fs_cache_t = now
return _fs_cache
func _button_style(accent: Color) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = Color(0.10, 0.07, 0.03, 0.80)
s.set_corner_radius_all(18)
s.corner_detail = 6
s.set_border_width_all(3)
s.border_color = accent
return s
# ── Menu ──────────────────────────────────────────────────────────────────────
func _return_to_menu() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
# Stay fullscreen across menu ↔ game so tapping Play never lands you windowed; the player
# leaves fullscreen only via the browser's own back / swipe / Esc, and the start gate then
# re-appears so the next tap goes back in.
get_tree().change_scene_to_file("res://ui/main_menu.tscn")
# ── Lifecycle ─────────────────────────────────────────────────────────────────
func _process(delta: float) -> void:
_hint_pulse = fmod(_hint_pulse + delta * 2.2, TAU)
var active := Controls.use_touch_ui() and not get_tree().paused
# The game-over / pause screen owns the display when paused — stand down and drop any
# held movement so the bull doesn't coast on a stuck joystick.
if not active:
if _surface.visible:
_surface.visible = false
_reset_touch_state()
return
_surface.visible = true
_prune_touch_state()
if _player == null:
var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty():
_player = players[0]
_surface.queue_redraw() # cooldown overlays animate every frame
# Fully drop every trace of in-flight touch input: release held movement, clear ownership,
# end the stick, cancel any pinch. Used on stand-down, on relayout, and on focus/pause loss.
func _reset_touch_state() -> void:
_end_stick()
_release_move()
_touches.clear()
_touch_owner.clear()
for i: int in _slot_pressed.size():
_slot_pressed[i] = false
_pinching = false
_pinch_dist = 0.0
# Per-frame self-heal: drop ownership entries whose finger is no longer down, and stand the
# stick down if the finger driving it has lifted. Catches a release that slipped through
# without a matching event so state can't quietly rot into a stuck stick.
func _prune_touch_state() -> void:
if not _touch_owner.is_empty():
var orphans: Array = []
for idx: int in _touch_owner:
if not _touches.has(idx):
orphans.append(idx)
for idx: int in orphans:
var owner: Variant = _touch_owner[idx]
if owner is int:
_slot_pressed[owner] = false
if _stick_active and idx == _stick_index:
_end_stick()
_touch_owner.erase(idx)
if _stick_active and not _touches.has(_stick_index):
_end_stick()
func _notification(what: int) -> void:
if (
what == NOTIFICATION_APPLICATION_FOCUS_OUT
or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT
or what == NOTIFICATION_APPLICATION_PAUSED
):
_reset_touch_state()
+1
View File
@@ -0,0 +1 @@
uid://d4ky4pbu70bo3
+36
View File
@@ -0,0 +1,36 @@
extends ColorRect
## Drives the fullscreen PS1/CRT post-process (ps1_filter.gdshader) from the DP
## registry so every uniform is live-tunable in the console. `show_ps1` toggles the
## whole overlay; the ps1_* floats feed the matching shader uniforms 1:1.
# DP param key → shader uniform name. Keys are registered in debug_params.gd with
# ranges that mirror the shader's hint_range, so nothing here can go out of bounds.
const _UNIFORMS := {
"ps1_pixel_scale": "pixel_scale",
"ps1_color_depth": "color_depth",
"ps1_dither_strength": "dither_strength",
"ps1_scanline_strength": "scanline_strength",
"ps1_roll_speed": "roll_speed",
"ps1_roll_strength": "roll_strength",
}
@onready var _mat: ShaderMaterial = material as ShaderMaterial
func _ready() -> void:
DP.any_changed.connect(_on_param_changed)
_apply_all()
# reset_all() fires with key "__all__"; a single edit fires with its own key. Either
# way just re-push everything — it's a handful of cheap uniform writes.
func _on_param_changed(_key: String, _value: Variant) -> void:
_apply_all()
func _apply_all() -> void:
visible = DP.b("show_ps1")
if _mat == null:
return
for key: String in _UNIFORMS:
_mat.set_shader_parameter(_UNIFORMS[key], DP.f(key))
+1
View File
@@ -0,0 +1 @@
uid://ce5n4qu88mhyx
+47
View File
@@ -0,0 +1,47 @@
shader_type canvas_item;
uniform sampler2D SCREEN_TEXTURE : hint_screen_texture, filter_nearest;
uniform float pixel_scale : hint_range(1.0, 8.0, 1.0) = 4.0;
uniform float color_depth : hint_range(2.0, 8.0, 1.0) = 5.0;
uniform float dither_strength : hint_range(0.0, 2.0, 0.05) = 0.8;
uniform float scanline_strength : hint_range(0.0, 1.0, 0.05) = 0.2;
uniform float roll_speed : hint_range(0.0, 10.0, 0.1) = 3.0;
uniform float roll_strength : hint_range(0.0, 1.0, 0.05) = 1.0;
float bayer4(ivec2 p) {
int x = p.x & 3;
int y = p.y & 3;
float m[16] = float[16](
0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0
);
return m[y * 4 + x] / 16.0 - 0.5;
}
void fragment() {
vec2 pixel_size = SCREEN_PIXEL_SIZE * pixel_scale;
vec2 snapped = (floor(SCREEN_UV / pixel_size) + 0.5) * pixel_size;
vec3 col = texture(SCREEN_TEXTURE, snapped).rgb;
ivec2 dpos = ivec2(FRAGCOORD.xy / pixel_scale);
float t = bayer4(dpos) * dither_strength;
float levels = exp2(color_depth) - 1.0;
col = clamp(floor(col * levels + t + 0.5) / levels, 0.0, 1.0);
if (scanline_strength > 0.0) {
// Rolling gradient mask scrolling top-to-bottom
float screen_y = FRAGCOORD.y * SCREEN_PIXEL_SIZE.y;
float roll_mask = sin(screen_y * TAU - TIME * roll_speed) * 0.5 + 0.5;
// Scanlines modulated by the roll: strong at peak, faint at trough
float effective_strength = scanline_strength * mix(1.0 - roll_strength, 1.0, roll_mask);
float row_frac = fract(FRAGCOORD.y / pixel_scale);
float scan = smoothstep(0.0, 0.3, row_frac) * (1.0 - smoothstep(0.7, 1.0, row_frac));
col *= 1.0 - effective_strength * (1.0 - scan);
}
COLOR = vec4(col, 1.0);
}
+1
View File
@@ -0,0 +1 @@
uid://d3xyyy5bh0ma
+151
View File
@@ -0,0 +1,151 @@
extends RefCounted
## Converts Skeleton3D-skinned meshes into NON-skinned mesh pieces parented to
## BoneAttachment3D nodes, so rendering never uses GPU vertex-skinning (transform
## feedback). Required on ANGLE/Vulkan/Mali mobile GPUs (e.g. Mali-G720 in mobile
## Chrome/Brave), where Godot's Compatibility skinning path renders skinned meshes
## invisible while non-skinned meshes draw fine.
##
## Each triangle is assigned wholesale to its dominant bone (highest summed weight),
## so segments stay watertight; joints become rigid (no smooth bending), which suits
## the low-poly look. Call RigidSkin.convert_tree(character_root) once after the scene
## is instanced (bones don't need to be posed yet — pieces are baked in bind space).
static func convert_tree(root: Node) -> int:
var converted := 0
for skel: Skeleton3D in _find(root, "Skeleton3D", []):
for mi: Node in skel.get_children():
if mi is MeshInstance3D and (mi as MeshInstance3D).skin != null \
and (mi as MeshInstance3D).mesh is ArrayMesh:
if _convert(skel, mi as MeshInstance3D):
converted += 1
return converted
static func _find(n: Node, klass: String, acc: Array) -> Array:
if n.is_class(klass):
acc.append(n)
for c: Node in n.get_children():
_find(c, klass, acc)
return acc
static func _convert(skel: Skeleton3D, mi: MeshInstance3D) -> bool:
var mesh := mi.mesh as ArrayMesh
var skin := mi.skin
# bind index -> (bone index, bind pose = mesh-space -> bone-local)
var bind_bone: PackedInt32Array = []
var bind_pose: Array[Transform3D] = []
for b: int in skin.get_bind_count():
var bone := skin.get_bind_bone(b)
if bone < 0:
bone = skel.find_bone(skin.get_bind_name(b))
bind_bone.append(bone)
bind_pose.append(skin.get_bind_pose(b))
# Accumulate SurfaceTool geometry per (destination bone, source surface). Keying on the
# surface too — not just the bone — is what preserves materials: a head bone that carries
# both the skin and hair surfaces must stay two pieces with two materials, otherwise every
# surface funnelled to a bone collapses onto one material (bald matadors, wrong uniforms).
var groups: Dictionary = {} # "bone:surface" -> {"st": SurfaceTool, "mat": Material, "bone": int}
for s: int in mesh.get_surface_count():
var arr := mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
var norms: PackedVector3Array = arr[Mesh.ARRAY_NORMAL]
var uvs: PackedVector2Array = arr[Mesh.ARRAY_TEX_UV] if arr[Mesh.ARRAY_TEX_UV] != null else PackedVector2Array()
var cols: PackedColorArray = arr[Mesh.ARRAY_COLOR] if arr[Mesh.ARRAY_COLOR] != null else PackedColorArray()
var bones: PackedInt32Array = arr[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arr[Mesh.ARRAY_WEIGHTS]
var idx: PackedInt32Array = arr[Mesh.ARRAY_INDEX]
# Honour a per-instance override (whole mesh) or per-surface override the scene set,
# falling back to the material baked into the mesh surface.
var mat: Material = mi.material_override
if mat == null:
mat = mi.get_surface_override_material(s)
if mat == null:
mat = mesh.surface_get_material(s)
var infl := 8 if (mesh.surface_get_format(s) & Mesh.ARRAY_FLAG_USE_8_BONE_WEIGHTS) else 4
var tri := PackedInt32Array()
if idx != null and idx.size() > 0:
tri = idx
else:
tri.resize(verts.size())
for i: int in verts.size():
tri[i] = i
for t: int in range(0, tri.size(), 3):
var a := tri[t]
var b := tri[t + 1]
var c := tri[t + 2]
var bind := _dominant_bind([a, b, c], bones, weights, infl)
var bone := bind_bone[bind]
var pose := bind_pose[bind]
var key := "%d:%d" % [bone, s]
var group: Dictionary = groups.get(key, {})
if group.is_empty():
var new_st := SurfaceTool.new()
new_st.begin(Mesh.PRIMITIVE_TRIANGLES)
group = {"st": new_st, "mat": mat, "bone": bone}
groups[key] = group
var st: SurfaceTool = group["st"]
for v: int in [a, b, c]:
if cols.size() > v:
st.set_color(cols[v])
if uvs.size() > v:
st.set_uv(uvs[v])
if norms.size() > v:
st.set_normal((pose.basis * norms[v]).normalized())
st.add_vertex(pose * verts[v])
if groups.is_empty():
return false
# Combine all of a bone's surface-groups into ONE mesh under ONE BoneAttachment (each group
# stays its own surface + material). Same draw calls as before, but roughly half the nodes to
# transform/cull every frame — which is what a phone feels while ragdolls drive the skeleton.
var by_bone: Dictionary = {} # bone:int -> Array[Dictionary]
for key: String in groups:
var group: Dictionary = groups[key]
var bone: int = group["bone"]
if not by_bone.has(bone):
by_bone[bone] = []
(by_bone[bone] as Array).append(group)
for bone: int in by_bone:
var att := BoneAttachment3D.new()
att.bone_name = skel.get_bone_name(bone)
skel.add_child(att)
var piece := MeshInstance3D.new()
var combined := ArrayMesh.new()
for group: Dictionary in by_bone[bone]:
var st: SurfaceTool = group["st"]
st.commit(combined)
if group["mat"] != null:
combined.surface_set_material(combined.get_surface_count() - 1, group["mat"])
piece.mesh = combined
att.add_child(piece)
# Hide (don't free) the original: keeps the node for any gameplay code that references
# it, and a hidden skinned mesh isn't drawn so it never hits the transform-feedback path.
mi.visible = false
return true
static func _dominant_bind(vs: Array, bones: PackedInt32Array, weights: PackedFloat32Array, infl: int) -> int:
var acc: Dictionary = {}
for v: int in vs:
for k: int in infl:
var w := weights[v * infl + k]
if w <= 0.0:
continue
var bind := bones[v * infl + k]
acc[bind] = float(acc.get(bind, 0.0)) + w
var best := 0
var best_w := -1.0
for bind: int in acc:
if acc[bind] > best_w:
best_w = acc[bind]
best = bind
return best
+1
View File
@@ -0,0 +1 @@
uid://dymd8vcfmtjts